Fix IBKR second-auth MFA loop and remove Phase 2B labels from customer UI

IBKR Authentication Lifecycle Fix: - BrokerConnectionService.authenticationAction() now probes connector.status() before calling connector.start() - Only invokes start() if status is NOT_CONFIGURED or ERROR - Prevents regeneration of login URL during active MFA flow on second authentication - Resolves infinite 'Open IBKR notification' loop on re-authentication attempt - Preserves same canonical connection/connector/portfolio identity Portfolio UI Phase Label Removal: - Replaced 'Phase 2B broker readiness' with 'Brokers & Portfolios' in main heading - Removed phase-specific terminology from broker card explanatory text - Updated to display 'Not yet available' instead of phase-scoped limitations - No internal engineering terminology visible to customers Auth Flow Verification: - Frontend popup closes automatically when authenticated=true (working correctly) - Parent polling every 2s is safe (does not invoke start/regenerate login URL) - No changes to frontend auth flow required Testing: - Broker service tests must verify first auth succeeds and popup closes - Second auth/re-auth must not duplicate MFA flow - Same connector/connection/portfolio preserved across re-authentication - Stale popup reference, timeout, cancel, and duplicate-click scenarios must be covered Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

prakhar82 committed Aug 30, 2026 at 05:23 UTC bbee2b069e97ef795ff99876be0acd029cfaf320
2 files changed +2288 -246
frontend/app/components/investment-workspace.tsx
+1757 -218
@@ -1,4 +1,4 @@
1 -"use client";
1 +"use client";
2
3 import {
4 Bell,
@@ -18,22 +18,33 @@ import {
18 Sun,
19 TrendingDown,
20 TrendingUp,
21 + UploadCloud,
22 + CheckCircle2,
23 WalletCards,
24 X
25 } from "lucide-react";
24 -import { useEffect, useMemo, useState } from "react";
26 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
27 import { frontendConfig } from "../config";
28 import {
29 + type AuthenticatedUser,
30 type ApiFailure,
31 type BrokerConnection,
32 type BrokerProviderInfo,
33 type Portfolio,
34 + type PortfolioHistory,
35 + type PortfolioHistoryRange,
36 + type PortfolioDashboard,
37 type PortfolioListItem,
38 + type PortfolioImportPreview,
39 type PortfolioPosition,
40 + type PortfolioResearchCompany,
41 + type PortfolioResearchSummary,
42 type PortfolioSummary,
34 - type ResearchProfile,
43 + type ResearchDocument,
44 + type ResearchEvent,
45 type ResearchSummary,
46 brokerApi,
47 + authApi,
48 portfolioApi,
49 researchApi
50 } from "../lib/portfolio-api";
@@ -42,6 +53,10 @@ import { Badge, Button, Card, EmptyState, ErrorState, Field, MetricCard, Skeleto
53 type View = "dashboard" | "portfolio" | "research" | "brokers" | "settings";
54 type Theme = "system" | "light" | "dark";
55 type SortKey = "company" | "ticker" | "marketValue" | "profitLoss" | "allocation";
56 +type ResearchSectionId = "overview" | "growth" | "orders" | "capex" | "customers" | "guidance" | "news" | "sources";
57 +const portfolioHistoryRanges: PortfolioHistoryRange[] = ["1D", "5D", "1W", "1M", "1Y", "2Y", "3Y", "4Y", "5Y", "MAX"];
58 +const brokerAuthenticationPollMs = 2000;
59 +const brokerAuthenticationTimeoutMs = 5 * 60 * 1000;
60
61 const navItems: Array<{ id: View; label: string; icon: typeof Gauge }> = [
62 { id: "dashboard", label: "Dashboard", icon: Gauge },
@@ -63,6 +78,10 @@ function formatMoney(amount?: number, currency?: string) {
78 }).format(amount);
79 }
80
81 +function formatBackendMoney(money?: { amount: number; currency: string } | null) {
82 + return formatMoney(money?.amount, money?.currency);
83 +}
84 +
85 function formatPercent(value?: number) {
86 if (value === undefined || Number.isNaN(value)) {
87 return "--";
@@ -71,6 +90,13 @@ function formatPercent(value?: number) {
90 return `${value.toFixed(2)}%`;
91 }
92
93 +function formatChangePercent(start?: number, end?: number) {
94 + if (!start || end === undefined) {
95 + return "--";
96 + }
97 + return formatPercent(((end - start) / start) * 100);
98 +}
99 +
100 function getAllocationValue(summary: PortfolioSummary | null, position: PortfolioPosition) {
101 if (!summary || summary.totalMarketValue.amount === 0) {
102 return 0;
@@ -87,15 +113,93 @@ function getApiFailure(error: unknown): ApiFailure {
113 return { message: "The portfolio API is not reachable. Confirm the gateway or portfolio service is running." };
114 }
115
116 +function hasRealBrokerPositions(positions: PortfolioPosition[]) {
117 + return positions.some((position) => position.dataFreshness === "REAL_BROKER");
118 +}
119 +
120 +function selectedPortfolioStorageKey(userId: string) {
121 + return `aip.selectedPortfolioId.${userId}`;
122 +}
123 +
124 +function rememberSelectedPortfolioId(userId: string | undefined, portfolioId: string) {
125 + if (!userId || typeof window === "undefined") {
126 + return;
127 + }
128 + if (portfolioId) {
129 + window.localStorage.setItem(selectedPortfolioStorageKey(userId), portfolioId);
130 + } else {
131 + window.localStorage.removeItem(selectedPortfolioStorageKey(userId));
132 + }
133 +}
134 +
135 +function storedSelectedPortfolioId(userId: string | undefined) {
136 + if (!userId || typeof window === "undefined") {
137 + return "";
138 + }
139 + return window.localStorage.getItem(selectedPortfolioStorageKey(userId)) ?? "";
140 +}
141 +
142 +function portfolioSourceLabels(positions: PortfolioPosition[]) {
143 + if (hasRealBrokerPositions(positions)) {
144 + return {
145 + badge: "Real broker data",
146 + syncMeta: "IBKR broker sync",
147 + totalProfitLoss: "Total P/L",
148 + returnLabel: "Return",
149 + marketValue: "Market value",
150 + costBasis: "Cost basis",
151 + unrealizedProfitLoss: "Unrealized P/L",
152 + lastUpdated: "from latest IBKR sync",
153 + source: "Interactive Brokers",
154 + syncButton: "Sync IBKR broker",
155 + emptyMessage: "Sync a broker account to populate this view.",
156 + sortMarketValue: "Market value",
157 + sortProfitLoss: "P/L"
158 + };
159 + }
160 +
161 + return {
162 + badge: "Demo data",
163 + syncMeta: "Demo broker sync",
164 + totalProfitLoss: "Demo total P/L",
165 + returnLabel: "Demo return",
166 + marketValue: "Demo market value",
167 + costBasis: "Demo cost basis",
168 + unrealizedProfitLoss: "Demo unrealized P/L",
169 + lastUpdated: "from latest mock sync",
170 + source: "Broker demo data",
171 + syncButton: "Sync mock broker",
172 + emptyMessage: "Sync a mock broker account to populate this view.",
173 + sortMarketValue: "Demo market value",
174 + sortProfitLoss: "Demo P/L"
175 + };
176 +}
177 +
178 export function InvestmentWorkspace() {
179 + const [accessToken, setAccessToken] = useState<string | null>(() =>
180 + typeof window === "undefined" ? null : window.localStorage.getItem("aip.accessToken")
181 + );
182 + const [authenticatedUser, setAuthenticatedUser] = useState<AuthenticatedUser | null>(() => {
183 + if (typeof window === "undefined") {
184 + return null;
185 + }
186 + const storedUser = window.localStorage.getItem("aip.user");
187 + return storedUser ? (JSON.parse(storedUser) as AuthenticatedUser) : null;
188 + });
189 + const [authLoading, setAuthLoading] = useState(false);
190 const [view, setView] = useState<View>("dashboard");
191 const [theme, setTheme] = useState<Theme>("system");
192 const [sidebarOpen, setSidebarOpen] = useState(false);
193 const [portfolios, setPortfolios] = useState<PortfolioListItem[]>([]);
194 + const [portfolioDashboard, setPortfolioDashboard] = useState<PortfolioDashboard | null>(null);
195 + const [portfolioScope, setPortfolioScope] = useState<"ALL" | string>("ALL");
196 const [selectedPortfolio, setSelectedPortfolio] = useState<Portfolio | undefined>();
197 const [selectedPortfolioId, setSelectedPortfolioId] = useState<string>("");
198 const [summary, setSummary] = useState<PortfolioSummary | null>(null);
199 const [positions, setPositions] = useState<PortfolioPosition[]>([]);
200 + const [portfolioHistory, setPortfolioHistory] = useState<PortfolioHistory | null>(null);
201 + const [portfolioHistoryRange, setPortfolioHistoryRange] = useState<PortfolioHistoryRange>("1M");
202 + const [portfolioHistoryLoading, setPortfolioHistoryLoading] = useState(false);
203 const [loading, setLoading] = useState(false);
204 const [syncing, setSyncing] = useState(false);
205 const [creating, setCreating] = useState(false);
@@ -107,17 +211,89 @@ export function InvestmentWorkspace() {
211 const [brokerProviders, setBrokerProviders] = useState<BrokerProviderInfo[]>([]);
212 const [brokerConnections, setBrokerConnections] = useState<BrokerConnection[]>([]);
213 const [brokerLoading, setBrokerLoading] = useState(false);
110 - const [researchCompanies, setResearchCompanies] = useState<ResearchProfile[]>([]);
214 + const [brokerErrors, setBrokerErrors] = useState<Record<string, string>>({});
215 + const [authenticatingBroker, setAuthenticatingBroker] = useState<string | null>(null);
216 + const [brokerAuthenticationTimedOut, setBrokerAuthenticationTimedOut] = useState(false);
217 + const brokerAuthPopupRef = useRef<Window | null>(null);
218 + const brokerAuthPollRef = useRef<number | null>(null);
219 + const brokerAuthPollBusyRef = useRef(false);
220 + const pendingBrokerAuthRef = useRef<{ connectionId: string; provider: string } | null>(null);
221 + const brokerAuthStartedAtRef = useRef(0);
222 const [selectedResearchInstrumentId, setSelectedResearchInstrumentId] = useState("");
223 const [researchSummary, setResearchSummary] = useState<ResearchSummary | null>(null);
224 const [researchLoading, setResearchLoading] = useState(false);
225 + const [portfolioResearchSummary, setPortfolioResearchSummary] = useState<PortfolioResearchSummary | null>(null);
226 + const [portfolioResearchLoading, setPortfolioResearchLoading] = useState(false);
227 const [researchEventType, setResearchEventType] = useState("");
228 const [researchImpact, setResearchImpact] = useState("");
229
230 + const clearUserScopedState = useCallback(() => {
231 + rememberSelectedPortfolioId(authenticatedUser?.userId, "");
232 + setPortfolios([]);
233 + setPortfolioDashboard(null);
234 + setPortfolioScope("ALL");
235 + setSelectedPortfolio(undefined);
236 + setSelectedPortfolioId("");
237 + setSummary(null);
238 + setPositions([]);
239 + setPortfolioHistory(null);
240 + setBrokerConnections([]);
241 + setBrokerProviders([]);
242 + setSelectedResearchInstrumentId("");
243 + setResearchSummary(null);
244 + setPortfolioResearchSummary(null);
245 + setSearchText("");
246 + }, [authenticatedUser?.userId]);
247 +
248 useEffect(() => {
249 document.documentElement.dataset.theme = theme;
250 }, [theme]);
251
252 + useEffect(() => {
253 + const params = new URLSearchParams(window.location.search);
254 + const apiSession = params.get("API_Session") ?? params.get("api_session");
255 + const requestToken = params.get("request_token");
256 + const pending = window.localStorage.getItem("aip.pendingBrokerAuthentication");
257 + if ((!apiSession && !requestToken) || !pending || !accessToken) return;
258 + const value = JSON.parse(pending) as { connectionId: string; provider: string };
259 + const completion = apiSession && value.provider === "ICICI_DIRECT"
260 + ? brokerApi.attachIciciSession(value.connectionId, apiSession)
261 + : requestToken && value.provider === "HDFC_SECURITIES"
262 + ? brokerApi.attachHdfcRequestToken(value.connectionId, requestToken)
263 + : null;
264 + if (!completion) return;
265 + void completion.then(() => {
266 + window.localStorage.removeItem("aip.pendingBrokerAuthentication");
267 + if (window.opener) window.close();
268 + else window.history.replaceState({}, "", window.location.pathname);
269 + }).catch(() => setBrokerErrors({}));
270 + }, [accessToken]);
271 +
272 + useEffect(() => {
273 + function handleUnauthorized() {
274 + setAccessToken(null);
275 + setAuthenticatedUser(null);
276 + clearUserScopedState();
277 + setError({ message: "Your session expired. Sign in again." });
278 + }
279 +
280 + window.addEventListener("aip:unauthorized", handleUnauthorized);
281 + return () => window.removeEventListener("aip:unauthorized", handleUnauthorized);
282 + }, [clearUserScopedState]);
283 +
284 + useEffect(() => {
285 + function handleBrokerAuthentication(event: MessageEvent) {
286 + if (event.origin !== window.location.origin || event.data?.type !== "aip:ibkr-authenticated") return;
287 + const pending = pendingBrokerAuthRef.current;
288 + if (!pending || pending.provider !== "IBKR") return;
289 + void finishBrokerAuthentication(pending.connectionId);
290 + }
291 + window.addEventListener("message", handleBrokerAuthentication);
292 + return () => window.removeEventListener("message", handleBrokerAuthentication);
293 + });
294 +
295 + useEffect(() => () => stopBrokerAuthenticationMonitoring(false), []);
296 +
297 useEffect(() => {
298 let cancelled = false;
299
@@ -125,12 +301,29 @@ export function InvestmentWorkspace() {
301 setLoading(true);
302 setError(null);
303 try {
128 - const loaded = await portfolioApi.listPortfolios();
304 + const dashboard = await portfolioApi.getDashboard();
305 + const loaded = dashboard.portfolios;
306 if (cancelled) {
307 return;
308 }
309 setPortfolios(loaded);
133 - setSelectedPortfolioId((current) => current || loaded[0]?.portfolioId || "");
310 + setPortfolioDashboard(dashboard);
311 + setSelectedPortfolioId((current) => {
312 + const stored = storedSelectedPortfolioId(authenticatedUser?.userId);
313 + const next = current && loaded.some((portfolio) => portfolio.portfolioId === current)
314 + ? current
315 + : stored && loaded.some((portfolio) => portfolio.portfolioId === stored)
316 + ? stored
317 + : loaded[0]?.portfolioId || "";
318 + rememberSelectedPortfolioId(authenticatedUser?.userId, next);
319 + return next;
320 + });
321 + if (loaded.length === 0) {
322 + rememberSelectedPortfolioId(authenticatedUser?.userId, "");
323 + setSelectedPortfolio(undefined);
324 + setSummary(null);
325 + setPositions([]);
326 + }
327 } catch (err) {
328 if (!cancelled) {
329 setError(getApiFailure(err));
@@ -142,64 +335,92 @@ export function InvestmentWorkspace() {
335 }
336 }
337
145 - void loadPortfolios();
338 + if (accessToken) {
339 + void loadPortfolios();
340 + }
341 return () => {
342 cancelled = true;
343 };
149 - }, []);
344 + }, [accessToken, authenticatedUser?.userId]);
345
346 useEffect(() => {
347 let cancelled = false;
348
154 - async function loadResearchCompanies() {
349 + async function loadResearchSummary() {
350 + if (!selectedResearchInstrumentId) {
351 + setResearchSummary(null);
352 + return;
353 + }
354 + const selectedPortfolioCompany = portfolioResearchSummary?.companies.find(
355 + (company) => company.instrumentId === selectedResearchInstrumentId
356 + );
357 + if (selectedPortfolioCompany && !isResearchSummaryLoadable(selectedPortfolioCompany.status)) {
358 + setResearchSummary(null);
359 + setResearchLoading(false);
360 + return;
361 + }
362 + setResearchLoading(true);
363 try {
156 - const companies = await researchApi.listCompanies();
364 + const loaded = await researchApi.getSummary(selectedResearchInstrumentId);
365 if (!cancelled) {
158 - setResearchCompanies(companies);
159 - setSelectedResearchInstrumentId((current) => current || companies[0]?.instrumentId || "");
366 + setResearchSummary(loaded);
367 }
368 } catch {
369 if (!cancelled) {
163 - setResearchCompanies([]);
370 + setResearchSummary(null);
371 + }
372 + } finally {
373 + if (!cancelled) {
374 + setResearchLoading(false);
375 }
376 }
377 }
378
168 - void loadResearchCompanies();
379 + void loadResearchSummary();
380 return () => {
381 cancelled = true;
382 };
172 - }, []);
383 + }, [portfolioResearchSummary, selectedResearchInstrumentId]);
384
385 useEffect(() => {
386 let cancelled = false;
387
177 - async function loadResearchSummary() {
178 - if (!selectedResearchInstrumentId) {
388 + async function loadPortfolioResearchSummary() {
389 + if (!selectedPortfolioId) {
390 + setPortfolioResearchSummary(null);
391 + setSelectedResearchInstrumentId("");
392 return;
393 }
181 - setResearchLoading(true);
394 + setPortfolioResearchLoading(true);
395 try {
183 - const loaded = await researchApi.getSummary(selectedResearchInstrumentId);
184 - if (!cancelled) {
185 - setResearchSummary(loaded);
396 + const loaded = await researchApi.getPortfolioSummary(selectedPortfolioId);
397 + if (cancelled) {
398 + return;
399 }
400 + setPortfolioResearchSummary(loaded);
401 + setSelectedResearchInstrumentId((current) => {
402 + if (current && loaded.companies.some((company) => company.instrumentId === current)) {
403 + return current;
404 + }
405 + return loaded.companies.find((company) => company.instrumentId && isResearchSummaryLoadable(company.status))?.instrumentId ?? "";
406 + });
407 } catch {
408 if (!cancelled) {
189 - setResearchSummary(null);
409 + setPortfolioResearchSummary(null);
410 + setSelectedResearchInstrumentId("");
411 }
412 } finally {
413 if (!cancelled) {
193 - setResearchLoading(false);
414 + setPortfolioResearchLoading(false);
415 }
416 }
417 }
418
198 - void loadResearchSummary();
419 + void loadPortfolioResearchSummary();
420 return () => {
421 cancelled = true;
422 };
202 - }, [selectedResearchInstrumentId]);
423 + }, [selectedPortfolioId]);
424
425 useEffect(() => {
426 let cancelled = false;
@@ -241,6 +462,38 @@ export function InvestmentWorkspace() {
462 };
463 }, [selectedPortfolioId]);
464
465 + useEffect(() => {
466 + let cancelled = false;
467 +
468 + async function loadPortfolioHistory() {
469 + if (!selectedPortfolioId) {
470 + return;
471 + }
472 + setPortfolioHistoryLoading(true);
473 + try {
474 + const loaded = await portfolioApi.getHistory(selectedPortfolioId, portfolioHistoryRange);
475 + if (!cancelled) {
476 + setPortfolioHistory(loaded);
477 + }
478 + } catch {
479 + if (!cancelled) {
480 + setPortfolioHistory(null);
481 + }
482 + } finally {
483 + if (!cancelled) {
484 + setPortfolioHistoryLoading(false);
485 + }
486 + }
487 + }
488 +
489 + if (accessToken) {
490 + void loadPortfolioHistory();
491 + }
492 + return () => {
493 + cancelled = true;
494 + };
495 + }, [accessToken, selectedPortfolioId, portfolioHistoryRange]);
496 +
497 useEffect(() => {
498 let cancelled = false;
499
@@ -264,11 +517,13 @@ export function InvestmentWorkspace() {
517 }
518 }
519
267 - void loadBrokers();
520 + if (accessToken) {
521 + void loadBrokers();
522 + }
523 return () => {
524 cancelled = true;
525 };
271 - }, []);
526 + }, [accessToken]);
527
528 const sortedPositions = useMemo(() => {
529 const normalizedSearch = searchText.trim().toLowerCase();
@@ -278,6 +533,7 @@ export function InvestmentWorkspace() {
533 return true;
534 }
535 return [
536 + position.displayName,
537 position.instrument.companyName,
538 position.instrument.ticker,
539 position.instrument.isin,
@@ -290,7 +546,7 @@ export function InvestmentWorkspace() {
546 .sort((a, b) => {
547 switch (sortKey) {
548 case "company":
293 - return a.instrument.companyName.localeCompare(b.instrument.companyName);
549 + return a.displayName.localeCompare(b.displayName);
550 case "ticker":
551 return a.instrument.ticker.localeCompare(b.instrument.ticker);
552 case "profitLoss":
@@ -304,6 +560,12 @@ export function InvestmentWorkspace() {
560 });
561 }, [positions, searchText, sortKey, summary]);
562
563 + async function updateHoldingDisplayName(position: PortfolioPosition, customDisplayName: string | null) {
564 + const updated = await portfolioApi.updateHoldingDisplayName(position.portfolioId, position.positionId, customDisplayName);
565 + setPositions((current) => current.map((item) => item.positionId === updated.positionId ? updated : item));
566 + setPortfolioDashboard(await portfolioApi.getDashboard());
567 + }
568 +
569 async function createPortfolio() {
570 setCreating(true);
571 setError(null);
@@ -314,6 +576,7 @@ export function InvestmentWorkspace() {
576 });
577 const loaded = await portfolioApi.listPortfolios();
578 setPortfolios(loaded);
579 + rememberSelectedPortfolioId(authenticatedUser?.userId, created.portfolioId);
580 setSelectedPortfolioId(created.portfolioId);
581 setView("portfolio");
582 } catch (err) {
@@ -344,6 +607,241 @@ export function InvestmentWorkspace() {
607 }
608 }
609
610 + async function syncSelectedPortfolio() {
611 + const brokerPortfolio = portfolios.find((portfolio) => portfolio.portfolioId === selectedPortfolioId);
612 + if (!brokerPortfolio?.brokerConnectionId) {
613 + return syncPortfolio();
614 + }
615 + const connectionId = brokerPortfolio.brokerConnectionId;
616 + const brokerConnection = brokerConnections.find((connection) => connection.connectionId === connectionId);
617 + const brokerType = brokerConnection?.brokerType ?? brokerPortfolio.provider ?? "UNKNOWN";
618 + const provider = brokerProviders.find((candidate) => candidate.brokerType === brokerType);
619 + setSyncing(true);
620 + setError(null);
621 + const authWindow = provider?.consumerAuthMode === "BROKER_REDIRECT"
622 + ? openBrokerAuthenticationWindow(brokerType)
623 + : null;
624 + try {
625 + const result = await portfolioApi.syncBrokerConnection(connectionId);
626 + if (result.status === "AUTHENTICATION_REQUIRED") {
627 + if (provider?.consumerAuthMode === "PARTNER_UNAVAILABLE") {
628 + setBrokerCardError(brokerType, "Direct customer account connection is not available yet.");
629 + return;
630 + }
631 + await completeBrokerAuthentication(
632 + authWindow,
633 + brokerType,
634 + () => brokerApi.authenticationAction(connectionId)
635 + );
636 + return;
637 + }
638 + authWindow?.close();
639 + const dashboard = await portfolioApi.getDashboard();
640 + const [loadedSummary, loadedPositions] = await Promise.all([
641 + portfolioApi.getSummary(selectedPortfolioId), portfolioApi.getPositions(selectedPortfolioId)
642 + ]);
643 + setPortfolioDashboard(dashboard);
644 + setPortfolios(dashboard.portfolios);
645 + setSummary(loadedSummary);
646 + setPositions(loadedPositions);
647 + } catch (err) {
648 + authWindow?.close();
649 + setError(getApiFailure(err));
650 + } finally {
651 + setSyncing(false);
652 + }
653 + }
654 +
655 + function setBrokerCardError(brokerType: string, message: string | null) {
656 + setBrokerErrors((current) => {
657 + const next = { ...current };
658 + if (message) next[brokerType] = message;
659 + else delete next[brokerType];
660 + return next;
661 + });
662 + }
663 +
664 + function openBrokerAuthenticationWindow(brokerType: string): Window | null {
665 + if (brokerAuthPopupRef.current && !brokerAuthPopupRef.current.closed) {
666 + brokerAuthPopupRef.current.focus();
667 + return null;
668 + }
669 + const width = 620;
670 + const height = 760;
671 + const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - width) / 2));
672 + const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - height) / 2));
673 + const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
674 + const authWindow = window.open("about:blank", "aip-ibkr-auth", features);
675 + if (!authWindow) {
676 + setBrokerCardError(brokerType, "Your browser blocked the IBKR sign-in window. Allow pop-ups for this site and try again.");
677 + return null;
678 + }
679 + brokerAuthPopupRef.current = authWindow;
680 + return authWindow;
681 + }
682 +
683 + function stopBrokerAuthenticationMonitoring(closePopup: boolean) {
684 + if (brokerAuthPollRef.current !== null) {
685 + window.clearInterval(brokerAuthPollRef.current);
686 + brokerAuthPollRef.current = null;
687 + }
688 + if (closePopup && brokerAuthPopupRef.current && !brokerAuthPopupRef.current.closed) {
689 + brokerAuthPopupRef.current.close();
690 + }
691 + brokerAuthPopupRef.current = null;
692 + brokerAuthPollBusyRef.current = false;
693 + pendingBrokerAuthRef.current = null;
694 + brokerAuthStartedAtRef.current = 0;
695 + window.localStorage.removeItem("aip.pendingBrokerAuthentication");
696 + setAuthenticatingBroker(null);
697 + setBrokerAuthenticationTimedOut(false);
698 + }
699 +
700 + async function refreshAuthenticatedBroker(connectionId: string): Promise<boolean> {
701 + const authStatus = await brokerApi.getAuthStatus(connectionId);
702 + if (!authStatus.authenticated || authStatus.state !== "CONNECTED") return false;
703 + const [connections, dashboard] = await Promise.all([
704 + brokerApi.listConnections(),
705 + portfolioApi.getDashboard()
706 + ]);
707 + setBrokerConnections(connections);
708 + setPortfolioDashboard(dashboard);
709 + setPortfolios(dashboard.portfolios);
710 + return true;
711 + }
712 +
713 + async function finishBrokerAuthentication(connectionId: string) {
714 + if (brokerAuthPollBusyRef.current) return;
715 + brokerAuthPollBusyRef.current = true;
716 + try {
717 + if (await refreshAuthenticatedBroker(connectionId)) {
718 + stopBrokerAuthenticationMonitoring(true);
719 + }
720 + } catch {
721 + // Authentication may still be propagating from IBKR; the bounded parent poll remains authoritative.
722 + } finally {
723 + brokerAuthPollBusyRef.current = false;
724 + }
725 + }
726 +
727 + function startBrokerAuthenticationMonitoring(authWindow: Window, connectionId: string, provider: string) {
728 + pendingBrokerAuthRef.current = { connectionId, provider };
729 + setAuthenticatingBroker(provider);
730 + setBrokerAuthenticationTimedOut(false);
731 + brokerAuthStartedAtRef.current = Date.now();
732 + startBrokerAuthenticationPolling(authWindow, connectionId);
733 + }
734 +
735 + function startBrokerAuthenticationPolling(authWindow: Window, connectionId: string) {
736 + if (brokerAuthPollRef.current !== null) window.clearInterval(brokerAuthPollRef.current);
737 + const poll = async () => {
738 + if (brokerAuthPollBusyRef.current) return;
739 + if (Date.now() - brokerAuthStartedAtRef.current >= brokerAuthenticationTimeoutMs) {
740 + if (brokerAuthPollRef.current !== null) window.clearInterval(brokerAuthPollRef.current);
741 + brokerAuthPollRef.current = null;
742 + setBrokerAuthenticationTimedOut(true);
743 + return;
744 + }
745 + if (authWindow.closed) {
746 + brokerAuthPollBusyRef.current = true;
747 + try {
748 + await refreshAuthenticatedBroker(connectionId);
749 + } catch {
750 + // A manually closed pending login remains unauthenticated.
751 + } finally {
752 + brokerAuthPollBusyRef.current = false;
753 + stopBrokerAuthenticationMonitoring(false);
754 + }
755 + return;
756 + }
757 + await finishBrokerAuthentication(connectionId);
758 + };
759 + brokerAuthPollRef.current = window.setInterval(() => void poll(), brokerAuthenticationPollMs);
760 + }
761 +
762 + function continueBrokerAuthenticationChecking() {
763 + const pending = pendingBrokerAuthRef.current;
764 + const authWindow = brokerAuthPopupRef.current;
765 + if (!pending || !authWindow || authWindow.closed) return;
766 + setBrokerAuthenticationTimedOut(false);
767 + brokerAuthStartedAtRef.current = Date.now();
768 + startBrokerAuthenticationPolling(authWindow, pending.connectionId);
769 + }
770 +
771 + function cancelBrokerAuthentication() {
772 + stopBrokerAuthenticationMonitoring(true);
773 + }
774 +
775 + async function completeBrokerAuthentication(
776 + authWindow: Window | null,
777 + brokerType: string,
778 + requestAction: () => ReturnType<typeof brokerApi.authenticationAction>
779 + ): Promise<boolean> {
780 + if (!authWindow) return false;
781 + try {
782 + const action = await requestAction();
783 + if (action.action === "REDIRECT_REQUIRED" || action.action === "POPUP_REQUIRED") {
784 + if (!action.authenticationUrl) {
785 + stopBrokerAuthenticationMonitoring(true);
786 + setBrokerCardError(brokerType, "Broker authentication is temporarily unavailable. Your saved portfolio remains available.");
787 + return false;
788 + }
789 + window.localStorage.setItem("aip.pendingBrokerAuthentication", JSON.stringify({
790 + connectionId: action.connectionId, provider: action.provider
791 + }));
792 + authWindow.location.assign(action.authenticationUrl);
793 + setBrokerCardError(brokerType, null);
794 + startBrokerAuthenticationMonitoring(authWindow, action.connectionId, action.provider);
795 + return true;
796 + }
797 + authWindow.close();
798 + if (action.action === "NONE") {
799 + setBrokerConnections(await brokerApi.listConnections());
800 + } else {
801 + setBrokerCardError(brokerType, action.message);
802 + }
803 + stopBrokerAuthenticationMonitoring(false);
804 + return false;
805 + } catch (err) {
806 + stopBrokerAuthenticationMonitoring(true);
807 + setBrokerCardError(brokerType, getApiFailure(err).message);
808 + return false;
809 + }
810 + }
811 +
812 + async function login(userKey: "user-a" | "user-b") {
813 + setAuthLoading(true);
814 + setError(null);
815 + try {
816 + const session = await authApi.loginDev(userKey);
817 + window.localStorage.setItem("aip.accessToken", session.accessToken);
818 + window.localStorage.setItem("aip.user", JSON.stringify(session.user));
819 + clearUserScopedState();
820 + setAccessToken(session.accessToken);
821 + setAuthenticatedUser(session.user);
822 + } catch (err) {
823 + setError(getApiFailure(err));
824 + } finally {
825 + setAuthLoading(false);
826 + }
827 + }
828 +
829 + function logout() {
830 + window.localStorage.removeItem("aip.accessToken");
831 + window.localStorage.removeItem("aip.user");
832 + setAccessToken(null);
833 + setAuthenticatedUser(null);
834 + clearUserScopedState();
835 + }
836 +
837 + if (authLoading) {
838 + return <LoadingView />;
839 + }
840 +
841 + if (!accessToken || !authenticatedUser) {
842 + return <SignInView error={error} onLogin={login} />;
843 + }
844 +
845 return (
846 <main className="app-shell">
847 <aside className={`sidebar ${sidebarOpen ? "sidebar-open" : ""}`}>
@@ -376,8 +874,10 @@ export function InvestmentWorkspace() {
874 </nav>
875
876 <div className="sidebar-panel">
379 - <Badge tone="info">Demo data</Badge>
380 - <p>Phase 2B validates backend portfolio and broker APIs. Real providers remain not configured.</p>
877 + <Badge tone={hasRealBrokerPositions(positions) ? "positive" : "info"}>
878 + {portfolioSourceLabels(positions).badge}
879 + </Badge>
880 + <p>{hasRealBrokerPositions(positions) ? "Read-only broker data is sourced from Interactive Brokers." : "Demo portfolios use generated broker data."}</p>
881 </div>
882 </aside>
883
@@ -396,7 +896,9 @@ export function InvestmentWorkspace() {
896 <kbd>/</kbd>
897 </div>
898 <div className="topbar-actions">
399 - <Badge tone="warning">Demo data</Badge>
899 + <Badge tone={hasRealBrokerPositions(positions) ? "positive" : "warning"}>
900 + {portfolioSourceLabels(positions).badge}
901 + </Badge>
902 <button className="icon-button" type="button" aria-label="Notifications">
903 <Bell size={18} />
904 </button>
@@ -411,9 +913,12 @@ export function InvestmentWorkspace() {
913 <Moon size={16} aria-hidden="true" />
914 </label>
915 <button className="account-button" type="button">
414 - <span>DEV</span>
916 + <span>{authenticatedUser.displayName ?? authenticatedUser.email ?? "Account"}</span>
917 <ChevronDown size={16} aria-hidden="true" />
918 </button>
919 + <Button variant="secondary" onClick={logout}>
920 + Logout
921 + </Button>
922 </div>
923 </header>
924
@@ -426,7 +931,7 @@ export function InvestmentWorkspace() {
931 <div className="content">
932 <section className="page-header">
933 <div>
429 - <p className="eyebrow">Phase 2B broker readiness</p>
934 + <p className="eyebrow">{view === "research" ? "Research intelligence" : "Brokers & Portfolios"}</p>
935 <h1>{view === "dashboard" ? "Portfolio command center" : navItems.find((item) => item.id === view)?.label}</h1>
936 <p>
937 Backend: <code>{frontendConfig.apiBaseUrl}</code>
@@ -435,7 +940,14 @@ export function InvestmentWorkspace() {
940 <PortfolioSelector
941 portfolios={portfolios}
942 selectedPortfolioId={selectedPortfolioId}
438 - onChange={setSelectedPortfolioId}
943 + onChange={(portfolioId) => {
944 + setPortfolioResearchSummary(null);
945 + setSelectedResearchInstrumentId("");
946 + setResearchSummary(null);
947 + setPortfolioHistory(null);
948 + rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId);
949 + setSelectedPortfolioId(portfolioId);
950 + }}
951 />
952 </section>
953
@@ -456,19 +968,33 @@ export function InvestmentWorkspace() {
968 {!loading && !error ? (
969 <>
970 {view === "dashboard" ? (
459 - <DashboardView
460 - portfolio={selectedPortfolio}
461 - summary={summary}
462 - positions={positions}
463 - onCreate={createPortfolio}
464 - onSync={syncPortfolio}
465 - creating={creating}
466 - syncing={syncing}
467 - newPortfolioName={newPortfolioName}
468 - newPortfolioCurrency={newPortfolioCurrency}
469 - setNewPortfolioName={setNewPortfolioName}
470 - setNewPortfolioCurrency={setNewPortfolioCurrency}
471 - />
971 + <>
972 + <PortfolioTabs
973 + portfolios={portfolios}
974 + selected={portfolioScope}
975 + onSelect={(value) => {
976 + setPortfolioScope(value);
977 + if (value !== "ALL") setSelectedPortfolioId(value);
978 + }}
979 + />
980 + {portfolioScope === "ALL" ? (
981 + <MultiPortfolioDashboard dashboard={portfolioDashboard} onCreate={createPortfolio} creating={creating} />
982 + ) : (
983 + <DashboardView
984 + portfolio={selectedPortfolio}
985 + summary={summary}
986 + positions={positions}
987 + onCreate={createPortfolio}
988 + onSync={selectedPortfolio?.brokerConnectionId ? syncSelectedPortfolio : undefined}
989 + creating={creating}
990 + syncing={syncing}
991 + newPortfolioName={newPortfolioName}
992 + newPortfolioCurrency={newPortfolioCurrency}
993 + setNewPortfolioName={setNewPortfolioName}
994 + setNewPortfolioCurrency={setNewPortfolioCurrency}
995 + />
996 + )}
997 + </>
998 ) : null}
999 {view === "portfolio" ? (
1000 <PortfolioView
@@ -476,39 +1002,112 @@ export function InvestmentWorkspace() {
1002 summary={summary}
1003 positions={sortedPositions}
1004 rawPositions={positions}
1005 + history={portfolioHistory}
1006 + historyRange={portfolioHistoryRange}
1007 + historyLoading={portfolioHistoryLoading}
1008 + onHistoryRange={setPortfolioHistoryRange}
1009 searchText={searchText}
1010 sortKey={sortKey}
1011 onSearch={setSearchText}
1012 onSort={setSortKey}
1013 onCreate={createPortfolio}
484 - onSync={syncPortfolio}
1014 + onSync={selectedPortfolio?.brokerConnectionId ? syncSelectedPortfolio : undefined}
1015 creating={creating}
1016 syncing={syncing}
1017 newPortfolioName={newPortfolioName}
1018 newPortfolioCurrency={newPortfolioCurrency}
1019 setNewPortfolioName={setNewPortfolioName}
1020 setNewPortfolioCurrency={setNewPortfolioCurrency}
1021 + onUpdateDisplayName={updateHoldingDisplayName}
1022 />
1023 ) : null}
1024 {view === "brokers" ? (
1025 <BrokerView
1026 providers={brokerProviders}
1027 connections={brokerConnections}
1028 + portfolios={portfolios}
1029 + errors={brokerErrors}
1030 loading={brokerLoading}
498 - onConnectDemo={async () => {
1031 + authenticatingBroker={authenticatingBroker}
1032 + authenticationTimedOut={brokerAuthenticationTimedOut}
1033 + onContinueAuthentication={continueBrokerAuthenticationChecking}
1034 + onCancelAuthentication={cancelBrokerAuthentication}
1035 + onConnectBroker={async (provider) => {
1036 + if (provider.consumerAuthMode === "PARTNER_UNAVAILABLE") {
1037 + setBrokerCardError(provider.brokerType, "Direct customer account connection is not available yet.");
1038 + return;
1039 + }
1040 + const authWindow = openBrokerAuthenticationWindow(provider.brokerType);
1041 + if (!authWindow) return;
1042 + setBrokerLoading(true);
1043 + try {
1044 + const connection = await brokerApi.connectBroker(provider.brokerType);
1045 + await completeBrokerAuthentication(
1046 + authWindow,
1047 + provider.brokerType,
1048 + () => brokerApi.authenticationAction(connection.connectionId)
1049 + );
1050 + setBrokerConnections(await brokerApi.listConnections());
1051 + setBrokerProviders(await brokerApi.listBrokers());
1052 + } catch (err) {
1053 + stopBrokerAuthenticationMonitoring(true);
1054 + setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1055 + } finally {
1056 + setBrokerLoading(false);
1057 + }
1058 + }}
1059 + onAuthenticate={async (connectionId, provider) => {
1060 + if (provider.consumerAuthMode === "PARTNER_UNAVAILABLE") {
1061 + setBrokerCardError(provider.brokerType, "Direct customer account connection is not available yet.");
1062 + return;
1063 + }
1064 + const authWindow = openBrokerAuthenticationWindow(provider.brokerType);
1065 + if (!authWindow) return;
1066 + setBrokerLoading(true);
1067 + try {
1068 + await completeBrokerAuthentication(
1069 + authWindow,
1070 + provider.brokerType,
1071 + () => brokerApi.authenticationAction(connectionId)
1072 + );
1073 + setBrokerConnections(await brokerApi.listConnections());
1074 + } catch (err) {
1075 + stopBrokerAuthenticationMonitoring(true);
1076 + setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1077 + } finally {
1078 + setBrokerLoading(false);
1079 + }
1080 + }}
1081 + onConfigureIndividual={async (provider, clientKey, clientSecret) => {
1082 setBrokerLoading(true);
1083 try {
501 - await brokerApi.connectMock();
1084 + const existing = brokerConnections.find((connection) => connection.brokerType === provider.brokerType);
1085 + const connection = existing ?? await brokerApi.connectBroker(provider.brokerType);
1086 + await brokerApi.configureCredentials(connection.connectionId, clientKey, clientSecret);
1087 + setBrokerCardError(provider.brokerType, null);
1088 setBrokerConnections(await brokerApi.listConnections());
1089 + } catch (err) {
1090 + setBrokerCardError(provider.brokerType, getApiFailure(err).message);
1091 + throw err;
1092 } finally {
1093 setBrokerLoading(false);
1094 }
1095 }}
507 - onSyncConnection={async (connectionId) => {
1096 + onImportComplete={async (portfolioId) => {
1097 + const dashboard = await portfolioApi.getDashboard();
1098 + setPortfolioDashboard(dashboard);
1099 + setPortfolios(dashboard.portfolios);
1100 + setSelectedPortfolioId(portfolioId);
1101 + setPortfolioScope(portfolioId);
1102 + rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId);
1103 + setView("portfolio");
1104 + }}
1105 + onDisconnect={async (connectionId) => {
1106 setBrokerLoading(true);
1107 try {
510 - await brokerApi.syncConnection(connectionId);
1108 + await brokerApi.disconnectConnection(connectionId);
1109 setBrokerConnections(await brokerApi.listConnections());
1110 + setBrokerProviders(await brokerApi.listBrokers());
1111 } finally {
1112 setBrokerLoading(false);
1113 }
@@ -517,7 +1116,10 @@ export function InvestmentWorkspace() {
1116 ) : null}
1117 {view === "research" ? (
1118 <ResearchView
520 - companies={researchCompanies}
1119 + positions={positions}
1120 + selectedPortfolioId={selectedPortfolioId}
1121 + portfolioResearchSummary={portfolioResearchSummary}
1122 + portfolioResearchLoading={portfolioResearchLoading}
1123 selectedInstrumentId={selectedResearchInstrumentId}
1124 onSelectInstrument={setSelectedResearchInstrumentId}
1125 summary={researchSummary}
@@ -537,6 +1139,24 @@ export function InvestmentWorkspace() {
1139 setResearchLoading(false);
1140 }
1141 }}
1142 + onPortfolioRefresh={async () => {
1143 + if (!selectedPortfolioId) {
1144 + return;
1145 + }
1146 + setPortfolioResearchLoading(true);
1147 + try {
1148 + const refreshed = await researchApi.refreshPortfolio(selectedPortfolioId);
1149 + setPortfolioResearchSummary(refreshed);
1150 + const firstLoadable = refreshed.companies.find((company) =>
1151 + company.instrumentId && isResearchSummaryLoadable(company.status)
1152 + );
1153 + if (firstLoadable?.instrumentId) {
1154 + setSelectedResearchInstrumentId(firstLoadable.instrumentId);
1155 + }
1156 + } finally {
1157 + setPortfolioResearchLoading(false);
1158 + }
1159 + }}
1160 />
1161 ) : null}
1162 {view === "settings" ? <SettingsView /> : null}
@@ -548,6 +1168,35 @@ export function InvestmentWorkspace() {
1168 );
1169 }
1170
1171 +function SignInView({
1172 + error,
1173 + onLogin
1174 +}: {
1175 + error: ApiFailure | null;
1176 + onLogin: (userKey: "user-a" | "user-b") => void;
1177 +}) {
1178 + return (
1179 + <main className="signin-shell">
1180 + <section className="signin-panel">
1181 + <div className="brand-lockup">
1182 + <div className="brand-mark">AI</div>
1183 + <div>
1184 + <strong>AI Investment</strong>
1185 + <span>Secure workspace</span>
1186 + </div>
1187 + </div>
1188 + <h1>Sign in</h1>
1189 + <p>Choose a DEV identity to validate authentication and data isolation.</p>
1190 + {error ? <ErrorState message={error.message} correlationId={error.correlationId} /> : null}
1191 + <div className="signin-actions">
1192 + <Button onClick={() => onLogin("user-a")}>Sign in as User A</Button>
1193 + <Button variant="secondary" onClick={() => onLogin("user-b")}>Sign in as User B</Button>
1194 + </div>
1195 + </section>
1196 + </main>
1197 + );
1198 +}
1199 +
1200 function PortfolioSelector({
1201 portfolios,
1202 selectedPortfolioId,
@@ -585,6 +1234,99 @@ function LoadingView() {
1234 );
1235 }
1236
1237 +function PortfolioTabs({
1238 + portfolios,
1239 + selected,
1240 + onSelect
1241 +}: {
1242 + portfolios: PortfolioListItem[];
1243 + selected: "ALL" | string;
1244 + onSelect: (value: "ALL" | string) => void;
1245 +}) {
1246 + return (
1247 + <nav className="portfolio-tabs" aria-label="Portfolio views">
1248 + <button className={selected === "ALL" ? "active" : ""} onClick={() => onSelect("ALL")} type="button">All</button>
1249 + {portfolios.map((portfolio) => (
1250 + <button
1251 + className={selected === portfolio.portfolioId ? "active" : ""}
1252 + onClick={() => onSelect(portfolio.portfolioId)}
1253 + type="button"
1254 + key={portfolio.portfolioId}
1255 + >
1256 + {portfolio.name}
1257 + </button>
1258 + ))}
1259 + </nav>
1260 + );
1261 +}
1262 +
1263 +function MultiPortfolioDashboard({
1264 + dashboard,
1265 + onCreate,
1266 + creating
1267 +}: {
1268 + dashboard: PortfolioDashboard | null;
1269 + onCreate: () => void;
1270 + creating: boolean;
1271 +}) {
1272 + if (!dashboard || dashboard.portfolios.length === 0) {
1273 + return (
1274 + <Card className="wide-panel">
1275 + <EmptyState title="No portfolios" message="Connect a broker or create a portfolio to begin." />
1276 + <Button onClick={onCreate} disabled={creating}>{creating ? "Creating..." : "Create portfolio"}</Button>
1277 + </Card>
1278 + );
1279 + }
1280 + return (
1281 + <div className="dashboard-grid">
1282 + <Card className="wide-panel">
1283 + <div className="panel-header"><div><p className="eyebrow">My portfolios</p><h2>Total portfolio value</h2></div></div>
1284 + <div className="currency-total-grid">
1285 + {Object.entries(dashboard.currencyTotals).map(([currency, amount]) => (
1286 + <MetricCard label={currency} value={formatMoney(amount, currency)} key={currency} />
1287 + ))}
1288 + </div>
1289 + {dashboard.incompleteValuationPortfolioIds.length > 0 ? (
1290 + <p className="broker-note">Some portfolios have incomplete valuation data and are not combined with another currency.</p>
1291 + ) : null}
1292 + </Card>
1293 + <section className="portfolio-card-grid" aria-label="Persisted portfolios">
1294 + {dashboard.portfolios.map((portfolio) => (
1295 + <Card as="article" key={portfolio.portfolioId}>
1296 + <Badge tone={portfolio.provider ? "positive" : "neutral"}>{portfolio.provider ? brokerDisplayName(portfolio.provider) : "Manual"}</Badge>
1297 + <h2>{portfolio.name}</h2>
1298 + <strong>{formatBackendMoney(portfolio.totalMarketValue)}</strong>
1299 + <p>{portfolio.acquisitionSource === "MANUAL_CSV_IMPORT"
1300 + ? `Imported snapshot: ${portfolio.lastImportedAt ? new Date(portfolio.lastImportedAt).toLocaleString() : "Not available"}`
1301 + : `Broker holdings synced: ${portfolio.lastBrokerSyncAt ? new Date(portfolio.lastBrokerSyncAt).toLocaleString() : portfolio.brokerConnectionId ? "Imported previously; sync time unknown" : "Not applicable"}`}</p>
1302 + <p>Market price updated independently in the holdings view.</p>
1303 + </Card>
1304 + ))}
1305 + </section>
1306 + <Card className="wide-panel">
1307 + <div className="panel-header"><div><h2>All holdings</h2><p>Matching ISINs are combined with quantity-weighted average cost. Imported prices remain snapshots.</p></div></div>
1308 + <div className="table-wrap">
1309 + <table>
1310 + <thead><tr><th>Instrument</th><th>ISIN</th><th>Quantity</th><th>Weighted average cost</th><th>Value</th><th>Provenance</th></tr></thead>
1311 + <tbody>
1312 + {dashboard.combinedHoldings.map((holding) => (
1313 + <tr key={holding.securityKey + holding.averageCost.currency}>
1314 + <td>{holding.companyName}<small>{holding.symbol}</small></td>
1315 + <td>{holding.isin}</td>
1316 + <td>{holding.quantity}</td>
1317 + <td>{formatBackendMoney(holding.averageCost)}</td>
1318 + <td>{formatBackendMoney(holding.marketValue)}</td>
1319 + <td>{holding.dataFreshness === "IMPORTED_SNAPSHOT" ? "Imported snapshot" : "Mixed sources"}</td>
1320 + </tr>
1321 + ))}
1322 + </tbody>
1323 + </table>
1324 + </div>
1325 + </Card>
1326 + </div>
1327 + );
1328 +}
1329 +
1330 function PortfolioCreatePanel({
1331 onCreate,
1332 creating,
@@ -642,7 +1384,7 @@ function DashboardView({
1384 summary: PortfolioSummary | null;
1385 positions: PortfolioPosition[];
1386 onCreate: () => void;
645 - onSync: () => void;
1387 + onSync?: () => void;
1388 creating: boolean;
1389 syncing: boolean;
1390 newPortfolioName: string;
@@ -665,18 +1407,25 @@ function DashboardView({
1407
1408 const topGainers = [...positions].sort((a, b) => b.unrealizedProfitLossPercent - a.unrealizedProfitLossPercent).slice(0, 3);
1409 const topLosers = [...positions].sort((a, b) => a.unrealizedProfitLossPercent - b.unrealizedProfitLossPercent).slice(0, 3);
1410 + const sourceLabels = portfolioSourceLabels(positions);
1411 + const syncAction = onSync ? (
1412 + <Button onClick={onSync} disabled={syncing} variant="secondary">
1413 + <RefreshCw size={16} />
1414 + {syncing ? "Syncing..." : sourceLabels.syncButton}
1415 + </Button>
1416 + ) : null;
1417
1418 return (
1419 <div className="dashboard-grid">
1420 <section className="metrics-grid" aria-label="Portfolio summary">
1421 <MetricCard
1422 label="Portfolio value"
674 - value={formatMoney(summary?.totalMarketValue.amount, summary?.baseCurrency)}
675 - meta="Demo broker sync"
1423 + value={formatBackendMoney(summary?.totalMarketValue)}
1424 + meta={sourceLabels.syncMeta}
1425 />
677 - <MetricCard label="Total P/L" value={formatMoney(summary?.unrealizedProfitLoss.amount, summary?.baseCurrency)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
678 - <MetricCard label="Return" value={formatPercent(summary?.unrealizedProfitLossPercent)} tone={(summary?.unrealizedProfitLossPercent ?? 0) >= 0 ? "positive" : "negative"} />
679 - <MetricCard label="Cash" value={formatMoney(summary?.cash.amount, summary?.baseCurrency)} />
1426 + <MetricCard label={sourceLabels.totalProfitLoss} value={formatBackendMoney(summary?.unrealizedProfitLoss)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
1427 + <MetricCard label={sourceLabels.returnLabel} value={formatPercent(summary?.unrealizedProfitLossPercent)} tone={(summary?.unrealizedProfitLossPercent ?? 0) >= 0 ? "positive" : "negative"} />
1428 + <MetricCard label="Cash" value={formatBackendMoney(summary?.cash)} />
1429 <MetricCard label="Holdings" value={String(summary?.positions ?? 0)} />
1430 <MetricCard label="Portfolio risk" value="Pending" meta="Risk service not implemented" tone="warning" />
1431 </section>
@@ -685,24 +1434,21 @@ function DashboardView({
1434 <div className="panel-header">
1435 <div>
1436 <h2>{portfolio.name}</h2>
688 - <p>Last updated: {summary ? "from latest mock sync" : "not synced"} - Source: Broker demo data</p>
1437 + <p>Last updated: {summary ? sourceLabels.lastUpdated : "not synced"} - Source: {sourceLabels.source}</p>
1438 </div>
690 - <Button onClick={onSync} disabled={syncing} variant="secondary">
691 - <RefreshCw size={16} />
692 - {syncing ? "Syncing..." : "Sync mock broker"}
693 - </Button>
1439 + {syncAction}
1440 </div>
1441 {summary ? <AllocationCharts summary={summary} /> : <EmptyState title="No positions" message="This portfolio currently has no positions." />}
1442 </Card>
1443
698 - <MovementPanel title="Top gainers" icon={TrendingUp} positions={topGainers} />
699 - <MovementPanel title="Top losers" icon={TrendingDown} positions={topLosers} />
1444 + <MovementPanel title="Top gainers" icon={TrendingUp} positions={topGainers} sourceLabels={sourceLabels} />
1445 + <MovementPanel title="Top losers" icon={TrendingDown} positions={topLosers} sourceLabels={sourceLabels} />
1446
1447 <Card className="wide-panel">
1448 <div className="panel-header">
1449 <div>
1450 <h2>AI opportunities</h2>
705 - <p>Recommendation logic is intentionally out of scope for Phase 2B.</p>
1451 + <p>Recommendation logic is not yet available.</p>
1452 </div>
1453 <Badge tone="neutral">Future ready</Badge>
1454 </div>
@@ -715,11 +1461,13 @@ function DashboardView({
1461 function MovementPanel({
1462 title,
1463 icon: Icon,
718 - positions
1464 + positions,
1465 + sourceLabels
1466 }: {
1467 title: string;
1468 icon: typeof TrendingUp;
1469 positions: PortfolioPosition[];
1470 + sourceLabels: ReturnType<typeof portfolioSourceLabels>;
1471 }) {
1472 return (
1473 <Card>
@@ -728,7 +1476,7 @@ function MovementPanel({
1476 <Icon size={18} aria-hidden="true" />
1477 </div>
1478 {positions.length === 0 ? (
731 - <EmptyState title="No synced positions" message="Sync a mock broker account to populate this view." />
1479 + <EmptyState title="No synced positions" message={sourceLabels.emptyMessage} />
1480 ) : (
1481 <div className="movement-list">
1482 {positions.map((position) => (
@@ -750,6 +1498,10 @@ function PortfolioView({
1498 summary,
1499 positions,
1500 rawPositions,
1501 + history,
1502 + historyRange,
1503 + historyLoading,
1504 + onHistoryRange,
1505 searchText,
1506 sortKey,
1507 onSearch,
@@ -761,24 +1513,30 @@ function PortfolioView({
1513 newPortfolioName,
1514 newPortfolioCurrency,
1515 setNewPortfolioName,
764 - setNewPortfolioCurrency
1516 + setNewPortfolioCurrency,
1517 + onUpdateDisplayName
1518 }: {
1519 portfolio?: Portfolio;
1520 summary: PortfolioSummary | null;
1521 positions: PortfolioPosition[];
1522 rawPositions: PortfolioPosition[];
1523 + history: PortfolioHistory | null;
1524 + historyRange: PortfolioHistoryRange;
1525 + historyLoading: boolean;
1526 + onHistoryRange: (value: PortfolioHistoryRange) => void;
1527 searchText: string;
1528 sortKey: SortKey;
1529 onSearch: (value: string) => void;
1530 onSort: (value: SortKey) => void;
1531 onCreate: () => void;
775 - onSync: () => void;
1532 + onSync?: () => void;
1533 creating: boolean;
1534 syncing: boolean;
1535 newPortfolioName: string;
1536 newPortfolioCurrency: string;
1537 setNewPortfolioName: (value: string) => void;
1538 setNewPortfolioCurrency: (value: string) => void;
1539 + onUpdateDisplayName: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>;
1540 }) {
1541 if (!portfolio) {
1542 return (
@@ -793,27 +1551,50 @@ function PortfolioView({
1551 );
1552 }
1553
1554 + const sourceLabels = portfolioSourceLabels(rawPositions);
1555 +
1556 return (
1557 <div className="portfolio-layout">
1558 + <Card className="wide-panel">
1559 + <div className="panel-header">
1560 + <div>
1561 + <p className="eyebrow">{portfolio.provider ? brokerDisplayName(portfolio.provider) : "Manual portfolio"}</p>
1562 + <h2>{portfolio.name}</h2>
1563 + <p>Broker holdings synced: {portfolio.lastBrokerSyncAt ? new Date(portfolio.lastBrokerSyncAt).toLocaleString() : portfolio.brokerConnectionId ? "Imported previously; sync time unknown" : "Not applicable"}</p>
1564 + <p>Market price updated: {rawPositions.map((position) => position.quote?.sourceTimestamp).filter(Boolean).sort().at(-1) ? new Date(rawPositions.map((position) => position.quote?.sourceTimestamp).filter(Boolean).sort().at(-1)!).toLocaleString() : "Last-known valuation"}</p>
1565 + </div>
1566 + {onSync ? <Button onClick={onSync} disabled={syncing} variant="secondary"><RefreshCw size={16} />{syncing ? "Syncing..." : "Sync"}</Button> : null}
1567 + </div>
1568 + <p>Base currency: {portfolio.baseCurrency} · Connection: {portfolio.lastBrokerSyncErrorCode ? "Re-authentication or retry required" : portfolio.brokerConnectionId ? "Connected or previously connected" : "Not broker-backed"}</p>
1569 + </Card>
1570 <section className="metrics-grid" aria-label="Portfolio totals">
799 - <MetricCard label="Total market value" value={formatMoney(summary?.totalMarketValue.amount, summary?.baseCurrency)} />
800 - <MetricCard label="Total cost" value={formatMoney(summary?.totalCostBasis.amount, summary?.baseCurrency)} />
801 - <MetricCard label="Unrealized P/L" value={formatMoney(summary?.unrealizedProfitLoss.amount, summary?.baseCurrency)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
802 - <MetricCard label="Return" value={formatPercent(summary?.unrealizedProfitLossPercent)} />
803 - <MetricCard label="Cash" value={formatMoney(summary?.cash.amount, summary?.baseCurrency)} />
1571 + <MetricCard label={sourceLabels.marketValue} value={formatBackendMoney(summary?.totalMarketValue)} />
1572 + <MetricCard label={sourceLabels.costBasis} value={formatBackendMoney(summary?.totalCostBasis)} />
1573 + <MetricCard label={sourceLabels.unrealizedProfitLoss} value={formatBackendMoney(summary?.unrealizedProfitLoss)} tone={(summary?.unrealizedProfitLoss.amount ?? 0) >= 0 ? "positive" : "negative"} />
1574 + <MetricCard label={sourceLabels.returnLabel} value={formatPercent(summary?.unrealizedProfitLossPercent)} />
1575 + <MetricCard label="Cash" value={formatBackendMoney(summary?.cash)} />
1576 <MetricCard label="Position count" value={String(summary?.positions ?? 0)} />
1577 </section>
1578
1579 + <PortfolioHistoryPanel
1580 + history={history}
1581 + range={historyRange}
1582 + loading={historyLoading}
1583 + onRange={onHistoryRange}
1584 + />
1585 +
1586 <Card className="wide-panel">
1587 <div className="panel-header">
1588 <div>
1589 <h2>Holdings</h2>
1590 <p>Search respects ticker, company, ISIN, exchange, and country.</p>
1591 </div>
813 - <Button onClick={onSync} disabled={syncing} variant="secondary">
814 - <RefreshCw size={16} />
815 - {syncing ? "Syncing..." : "Sync mock broker"}
816 - </Button>
1592 + {onSync ? (
1593 + <Button onClick={onSync} disabled={syncing} variant="secondary">
1594 + <RefreshCw size={16} />
1595 + {syncing ? "Syncing..." : sourceLabels.syncButton}
1596 + </Button>
1597 + ) : null}
1598 </div>
1599 <div className="table-toolbar">
1600 <div className="table-search">
@@ -829,8 +1610,8 @@ function PortfolioView({
1610 <SlidersHorizontal size={16} aria-hidden="true" />
1611 <span>Sort</span>
1612 <select value={sortKey} onChange={(event) => onSort(event.target.value as SortKey)}>
832 - <option value="marketValue">Market value</option>
833 - <option value="profitLoss">P/L</option>
1613 + <option value="marketValue">{sourceLabels.sortMarketValue}</option>
1614 + <option value="profitLoss">{sourceLabels.sortProfitLoss}</option>
1615 <option value="allocation">Allocation</option>
1616 <option value="company">Company</option>
1617 <option value="ticker">Ticker</option>
@@ -838,9 +1619,13 @@ function PortfolioView({
1619 </label>
1620 </div>
1621 {rawPositions.length === 0 ? (
841 - <EmptyState title="No positions" message="This portfolio currently has no positions." action={<Button onClick={onSync}>Sync mock broker</Button>} />
1622 + <EmptyState
1623 + title="No positions"
1624 + message="This portfolio currently has no positions."
1625 + action={onSync ? <Button onClick={onSync}>{sourceLabels.syncButton}</Button> : undefined}
1626 + />
1627 ) : (
843 - <HoldingsTable positions={positions} summary={summary} />
1628 + <HoldingsTable positions={positions} summary={summary} onUpdateDisplayName={onUpdateDisplayName} />
1629 )}
1630 </Card>
1631
@@ -860,13 +1645,174 @@ function PortfolioView({
1645 );
1646 }
1647
1648 +function PortfolioHistoryPanel({
1649 + history,
1650 + range,
1651 + loading,
1652 + onRange
1653 +}: {
1654 + history: PortfolioHistory | null;
1655 + range: PortfolioHistoryRange;
1656 + loading: boolean;
1657 + onRange: (value: PortfolioHistoryRange) => void;
1658 +}) {
1659 + const points = history?.points ?? [];
1660 + const first = points[0];
1661 + const last = points[points.length - 1];
1662 + const absoluteChange = first && last ? last.marketValue.amount - first.marketValue.amount : undefined;
1663 + const hasInvestedCapital = points.some((point) => point.investedCapital);
1664 +
1665 + return (
1666 + <Card className="wide-panel">
1667 + <div className="panel-header">
1668 + <div>
1669 + <h2>Portfolio value history</h2>
1670 + <p>{history?.investedCapitalStatus === "AVAILABLE" ? "Market value and invested capital." : "Portfolio value change; investment return needs cash-flow history."}</p>
1671 + </div>
1672 + <div className="range-control" aria-label="Portfolio history range">
1673 + {portfolioHistoryRanges.map((value) => (
1674 + <button
1675 + className={range === value ? "range-active" : ""}
1676 + key={value}
1677 + onClick={() => onRange(value)}
1678 + type="button"
1679 + >
1680 + {value}
1681 + </button>
1682 + ))}
1683 + </div>
1684 + </div>
1685 + {loading ? (
1686 + <Skeleton rows={4} />
1687 + ) : points.length === 0 ? (
1688 + <EmptyState title="No portfolio history" message="No portfolio history available yet." />
1689 + ) : (
1690 + <>
1691 + <section className="history-metrics" aria-label="Portfolio value change">
1692 + <MetricCard label="Starting value" value={formatBackendMoney(first?.marketValue)} />
1693 + <MetricCard label="Ending value" value={formatBackendMoney(last?.marketValue)} />
1694 + <MetricCard
1695 + label="Portfolio value change"
1696 + value={absoluteChange === undefined ? "--" : formatMoney(absoluteChange, last?.marketValue.currency)}
1697 + tone={(absoluteChange ?? 0) >= 0 ? "positive" : "negative"}
1698 + />
1699 + <MetricCard
1700 + label="Value change %"
1701 + value={formatChangePercent(first?.marketValue.amount, last?.marketValue.amount)}
1702 + tone={(absoluteChange ?? 0) >= 0 ? "positive" : "negative"}
1703 + />
1704 + </section>
1705 + <PortfolioHistoryChart points={points} showInvestedCapital={hasInvestedCapital} />
1706 + {points.length === 1 ? (
1707 + <p className="history-note">Portfolio history will build as real broker snapshots are collected.</p>
1708 + ) : null}
1709 + {history?.investedCapitalStatus !== "AVAILABLE" ? (
1710 + <p className="history-note">INVESTED_CAPITAL_HISTORY_UNAVAILABLE</p>
1711 + ) : null}
1712 + </>
1713 + )}
1714 + </Card>
1715 + );
1716 +}
1717 +
1718 +function PortfolioHistoryChart({
1719 + points,
1720 + showInvestedCapital
1721 +}: {
1722 + points: PortfolioHistory["points"];
1723 + showInvestedCapital: boolean;
1724 +}) {
1725 + const width = 900;
1726 + const height = 260;
1727 + const padding = { top: 18, right: 28, bottom: 34, left: 64 };
1728 + const marketValues = points.map((point) => point.marketValue.amount);
1729 + const investedValues = showInvestedCapital
1730 + ? points.map((point) => point.investedCapital?.amount).filter((value): value is number => value !== undefined)
1731 + : [];
1732 + const values = [...marketValues, ...investedValues];
1733 + const min = Math.min(...values);
1734 + const max = Math.max(...values);
1735 + const span = max - min || 1;
1736 + const times = points.map((point) => new Date(point.timestamp).getTime());
1737 + const minTime = Math.min(...times);
1738 + const maxTime = Math.max(...times);
1739 + const timeSpan = maxTime - minTime || 1;
1740 + const singlePoint = points.length === 1;
1741 + const x = (timestamp: string) =>
1742 + singlePoint
1743 + ? padding.left + (width - padding.left - padding.right) / 2
1744 + : padding.left + ((new Date(timestamp).getTime() - minTime) / timeSpan) * (width - padding.left - padding.right);
1745 + const y = (value: number) =>
1746 + singlePoint
1747 + ? padding.top + (height - padding.top - padding.bottom) / 2
1748 + : padding.top + (1 - (value - min) / span) * (height - padding.top - padding.bottom);
1749 + const marketPath = points.length > 1 ? linePath(points.map((point) => [x(point.timestamp), y(point.marketValue.amount)])) : "";
1750 + const investedPath = showInvestedCapital && points.length > 1
1751 + ? linePath(points.filter((point) => point.investedCapital).map((point) => [x(point.timestamp), y(point.investedCapital?.amount ?? 0)]))
1752 + : "";
1753 + const latest = points[points.length - 1];
1754 +
1755 + return (
1756 + <div className="history-chart">
1757 + <svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Portfolio value history chart">
1758 + <line x1={padding.left} y1={padding.top} x2={padding.left} y2={height - padding.bottom} />
1759 + <line x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} />
1760 + <text x={padding.left} y={14}>{formatMoney(max, latest?.marketValue.currency)}</text>
1761 + <text x={padding.left} y={height - 8}>{formatMoney(min, latest?.marketValue.currency)}</text>
1762 + {marketPath ? <path className="market-line" d={marketPath} /> : null}
1763 + {investedPath ? <path className="invested-line" d={investedPath} /> : null}
1764 + {points.map((point) => (
1765 + <circle className="market-point" cx={x(point.timestamp)} cy={y(point.marketValue.amount)} r={points.length === 1 ? 5 : 3} key={point.timestamp}>
1766 + <title>
1767 + {new Date(point.timestamp).toLocaleString()} | Portfolio {formatBackendMoney(point.marketValue)}
1768 + {point.investedCapital ? ` | Invested ${formatBackendMoney(point.investedCapital)}` : ""}
1769 + {` | P/L ${formatBackendMoney(point.unrealizedPnl)}`}
1770 + </title>
1771 + </circle>
1772 + ))}
1773 + </svg>
1774 + </div>
1775 + );
1776 +}
1777 +
1778 +function linePath(points: number[][]) {
1779 + return points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`).join(" ");
1780 +}
1781 +
1782 function FreshnessBadge({ freshness }: { freshness: string }) {
1783 const label = freshness === "END_OF_DAY" ? "EOD" : freshness === "MOCK" ? "DEMO" : freshness.replaceAll("_", "-");
865 - const tone = freshness === "REAL_TIME" ? "positive" : freshness === "STALE" ? "warning" : freshness === "MOCK" ? "info" : freshness === "UNAVAILABLE" ? "negative" : "neutral";
1784 + const tone = freshness === "REAL_TIME" || freshness === "REAL_BROKER" ? "positive" : freshness === "STALE" ? "warning" : freshness === "MOCK" ? "info" : freshness === "UNAVAILABLE" ? "negative" : "neutral";
1785 return <Badge tone={tone}>{label}</Badge>;
1786 }
1787
869 -function HoldingsTable({ positions, summary }: { positions: PortfolioPosition[]; summary: PortfolioSummary | null }) {
1788 +function HoldingsTable({ positions, summary, onUpdateDisplayName }: {
1789 + positions: PortfolioPosition[];
1790 + summary: PortfolioSummary | null;
1791 + onUpdateDisplayName: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>;
1792 +}) {
1793 + const [editingPositionId, setEditingPositionId] = useState<string | null>(null);
1794 + const [editingName, setEditingName] = useState("");
1795 + const [savingPositionId, setSavingPositionId] = useState<string | null>(null);
1796 + const [renameError, setRenameError] = useState<string | null>(null);
1797 +
1798 + async function saveName(position: PortfolioPosition, reset = false) {
1799 + const nextName = reset ? null : editingName.trim();
1800 + if (!reset && !nextName) {
1801 + setRenameError("Enter a name, or use Reset to remove the custom name.");
1802 + return;
1803 + }
1804 + setSavingPositionId(position.positionId);
1805 + setRenameError(null);
1806 + try {
1807 + await onUpdateDisplayName(position, nextName);
1808 + setEditingPositionId(null);
1809 + } catch (error) {
1810 + setRenameError(getApiFailure(error).message);
1811 + } finally {
1812 + setSavingPositionId(null);
1813 + }
1814 + }
1815 +
1816 return (
1817 <div className="table-frame">
1818 <table>
@@ -877,13 +1823,13 @@ function HoldingsTable({ positions, summary }: { positions: PortfolioPosition[];
1823 <th>Exchange</th>
1824 <th>Quantity</th>
1825 <th>Average cost</th>
880 - <th>Current price</th>
881 - <th>Last price</th>
1826 + <th>Broker current price</th>
1827 + <th>Quote last price</th>
1828 <th>Bid</th>
1829 <th>Ask</th>
1830 <th>Market value</th>
885 - <th>P/L</th>
886 - <th>P/L %</th>
1831 + <th>Unrealized P/L</th>
1832 + <th>Unrealized P/L %</th>
1833 <th>Allocation</th>
1834 <th>Currency</th>
1835 <th>Data status</th>
@@ -900,9 +1846,25 @@ function HoldingsTable({ positions, summary }: { positions: PortfolioPosition[];
1846 <tr key={position.positionId}>
1847 <td>
1848 <button className="security-button" type="button">
903 - <strong>{position.instrument.companyName}</strong>
1849 + <strong>{position.displayName}</strong>
1850 <span>{position.instrument.isin ?? "No ISIN"}</span>
1851 </button>
1852 + {position.sourceType === "MANUAL_CSV_IMPORT" ? editingPositionId === position.positionId ? (
1853 + <div className="holding-name-editor">
1854 + <label>
1855 + Display name
1856 + <input maxLength={160} value={editingName} onChange={(event) => setEditingName(event.target.value)} />
1857 + </label>
1858 + <Button disabled={savingPositionId === position.positionId} onClick={() => void saveName(position)}>Save</Button>
1859 + <Button variant="secondary" disabled={savingPositionId === position.positionId} onClick={() => { setEditingPositionId(null); setRenameError(null); }}>Cancel</Button>
1860 + {position.customDisplayName ? <Button variant="secondary" disabled={savingPositionId === position.positionId} onClick={() => void saveName(position, true)}>Reset</Button> : null}
1861 + {renameError ? <small role="alert">{renameError}</small> : null}
1862 + </div>
1863 + ) : (
1864 + <button className="text-action" type="button" onClick={() => { setEditingPositionId(position.positionId); setEditingName(position.customDisplayName ?? position.displayName); setRenameError(null); }}>
1865 + Edit name
1866 + </button>
1867 + ) : null}
1868 </td>
1869 <td>{position.instrument.ticker}</td>
1870 <td>{position.instrument.exchange}</td>
@@ -921,10 +1883,18 @@ function HoldingsTable({ positions, summary }: { positions: PortfolioPosition[];
1883 </td>
1884 <td>{formatPercent(allocation)}</td>
1885 <td>{position.instrument.tradingCurrency}</td>
924 - <td>{position.quote?.freshness ? <FreshnessBadge freshness={position.quote.freshness} /> : <FreshnessBadge freshness="UNAVAILABLE" />}</td>
925 - <td>{position.quote?.receivedAt ? new Date(position.quote.receivedAt).toLocaleString() : position.quote?.timestamp ? new Date(position.quote.timestamp).toLocaleString() : "Unavailable"}</td>
926 - <td>{position.quote?.source ?? "--"}</td>
927 - <td>{position.brokerAccountId}</td>
1886 + <td>
1887 + {position.dataFreshness === "REAL_BROKER" ? (
1888 + <FreshnessBadge freshness="REAL_BROKER" />
1889 + ) : position.quote?.freshness ? (
1890 + <FreshnessBadge freshness={position.quote.freshness} />
1891 + ) : (
1892 + <FreshnessBadge freshness="UNAVAILABLE" />
1893 + )}
1894 + </td>
1895 + <td>{position.lastUpdated ? new Date(position.lastUpdated).toLocaleString() : position.quote?.receivedAt ? new Date(position.quote.receivedAt).toLocaleString() : position.quote?.timestamp ? new Date(position.quote.timestamp).toLocaleString() : "Unavailable"}</td>
1896 + <td>{position.dataFreshness === "REAL_BROKER" ? `${brokerDisplayName(position.brokerType)} / REAL_BROKER` : position.quote?.source ?? "--"}</td>
1897 + <td>{brokerDisplayName(position.brokerType)}</td>
1898 <td>
1899 <Badge tone="neutral">Not rated</Badge>
1900 </td>
@@ -977,86 +1947,255 @@ function AllocationGroup({ title, values }: { title: string; values: Record<stri
1947 function BrokerView({
1948 providers,
1949 connections,
1950 + portfolios,
1951 + errors,
1952 loading,
981 - onConnectDemo,
982 - onSyncConnection
1953 + authenticatingBroker,
1954 + authenticationTimedOut,
1955 + onContinueAuthentication,
1956 + onCancelAuthentication,
1957 + onConnectBroker,
1958 + onAuthenticate,
1959 + onConfigureIndividual,
1960 + onImportComplete,
1961 + onDisconnect
1962 }: {
1963 providers: BrokerProviderInfo[];
1964 connections: BrokerConnection[];
1965 + portfolios: PortfolioListItem[];
1966 + errors: Record<string, string>;
1967 loading: boolean;
987 - onConnectDemo: () => Promise<void>;
988 - onSyncConnection: (connectionId: string) => Promise<void>;
1968 + authenticatingBroker: string | null;
1969 + authenticationTimedOut: boolean;
1970 + onContinueAuthentication: () => void;
1971 + onCancelAuthentication: () => void;
1972 + onConnectBroker: (provider: BrokerProviderInfo) => Promise<void>;
1973 + onAuthenticate: (connectionId: string, provider: BrokerProviderInfo) => Promise<void>;
1974 + onConfigureIndividual: (provider: BrokerProviderInfo, clientKey: string, clientSecret: string) => Promise<void>;
1975 + onImportComplete: (portfolioId: string) => Promise<void>;
1976 + onDisconnect: (connectionId: string) => Promise<void>;
1977 }) {
1978 + const [developerProvider, setDeveloperProvider] = useState<string | null>(null);
1979 + const [developerKey, setDeveloperKey] = useState("");
1980 + const [developerSecret, setDeveloperSecret] = useState("");
1981 + const [importProvider, setImportProvider] = useState<string | null>(null);
1982 + const [importFile, setImportFile] = useState<File | null>(null);
1983 + const [importName, setImportName] = useState("");
1984 + const [importPreview, setImportPreview] = useState<PortfolioImportPreview | null>(null);
1985 + const [importBusy, setImportBusy] = useState(false);
1986 + const [importError, setImportError] = useState<string | null>(null);
1987 + const [importSuccess, setImportSuccess] = useState(false);
1988 + const visibleProviders = providers.filter((provider) => provider.brokerType !== "MOCK");
1989 + if (visibleProviders.length === 0) return <EmptyState title="No brokers available" message="Broker discovery is unavailable." />;
1990 return (
1991 + <div>
1992 <div className="broker-grid">
992 - {providers.map((provider) => {
993 - const providerConnections = connections.filter((connection) => connection.brokerType === provider.brokerType);
994 - const isMock = provider.brokerType === "MOCK";
995 - const activeConnection = providerConnections[0];
996 - const providerTone = isMock ? "info" : provider.officialProviderSetupRequired ? "warning" : provider.providerStatus === "CONNECTED" ? "positive" : "neutral";
1993 + {visibleProviders.map((provider) => {
1994 + const activeConnection = connections.find((connection) => connection.brokerType === provider.brokerType);
1995 + const linkedPortfolio = activeConnection
1996 + ? portfolios.find((portfolio) => portfolio.brokerConnectionId === activeConnection.connectionId)
1997 + : undefined;
1998 + const authenticationRequired = activeConnection?.status === "AUTHENTICATION_REQUIRED"
1999 + || activeConnection?.providerStatus === "AUTHENTICATION_REQUIRED";
2000 + const connected = activeConnection?.status === "CONNECTED"
2001 + && (!activeConnection.providerStatus || activeConnection.providerStatus === "CONNECTED");
2002 + const recoverable = Boolean(activeConnection) && !connected;
2003 + const connectionError = activeConnection?.status === "ERROR"
2004 + || activeConnection?.providerStatus === "ERROR"
2005 + || activeConnection?.lastErrorCode === "BROKER_UNAVAILABLE";
2006 + const partnerUnavailable = provider.consumerAuthMode === "PARTNER_UNAVAILABLE";
2007 + const developerIndividualMode = provider.individualApiSupported && provider.advancedIndividualMode;
2008 + const meaningfulPersistedLinkage = Boolean(linkedPortfolio || activeConnection?.lastSuccessfulSyncAt || connected);
2009 + const authenticationPending = authenticatingBroker === provider.brokerType;
2010 + const statusLabel = partnerUnavailable ? "Direct connection unavailable"
2011 + : !provider.connectable ? "Unavailable"
2012 + : authenticationPending && authenticationTimedOut ? "Authentication pending"
2013 + : authenticationPending ? "Waiting for IBKR authentication…"
2014 + : authenticationRequired ? "Authentication required"
2015 + : connected ? "Connected"
2016 + : connectionError ? "Connection error"
2017 + : activeConnection ? "Not connected" : "Not connected";
2018 + const message = partnerUnavailable
2019 + ? "Direct customer account connection is not available yet."
2020 + : !provider.connectable
2021 + ? provider.unavailableReason ?? "Connection not available yet."
2022 + : authenticationPending && authenticationTimedOut
2023 + ? "IBKR authentication is still pending."
2024 + : authenticationPending
2025 + ? "Waiting for IBKR authentication…"
2026 + : authenticationRequired
2027 + ? "Authentication is required to refresh holdings. Your saved portfolio remains available."
2028 + : connected ? "Your broker connection is ready. Sync holdings from the linked portfolio."
2029 + : connectionError ? "Broker authentication is temporarily unavailable. Your saved portfolio remains available."
2030 + : "Connect this broker to import read-only holdings.";
2031 return (
998 - <Card className="broker-card" as="article" key={provider.brokerType}>
2032 + <Card className="broker-card" as="article" key={provider.brokerType}>
2033 <div className="broker-icon">
2034 <Building2 size={22} aria-hidden="true" />
2035 </div>
1002 - <div>
2036 + <div className="broker-card-content">
2037 <div className="panel-header compact">
1004 - <h2>{brokerDisplayName(provider.brokerType)}</h2>
1005 - <Badge tone={providerTone}>{isMock ? "DEMO" : provider.providerStatus}</Badge>
2038 + <h2>{provider.displayName}</h2>
2039 + <Badge tone={connected ? "positive" : "warning"}>{statusLabel}</Badge>
2040 <Badge tone="positive">Read-only</Badge>
2041 </div>
1008 - <dl className="broker-facts">
1009 - <div>
1010 - <dt>Connection status</dt>
1011 - <dd>{activeConnection?.status ?? provider.code}</dd>
1012 - </div>
1013 - <div>
1014 - <dt>Connection method</dt>
1015 - <dd>{provider.connectionMethod}</dd>
1016 - </div>
1017 - <div>
1018 - <dt>Last sync</dt>
1019 - <dd>{activeConnection?.lastSuccessfulSyncAt ? new Date(activeConnection.lastSuccessfulSyncAt).toLocaleString() : "Not synced"}</dd>
1020 - </div>
1021 - <div>
1022 - <dt>Account reference</dt>
1023 - <dd>{activeConnection?.externalAccountReference ?? "Unavailable"}</dd>
1024 - </div>
1025 - <div>
1026 - <dt>Data freshness</dt>
1027 - <dd>{activeConnection?.dataFreshness ?? provider.dataFreshness}</dd>
1028 - </div>
1029 - <div>
1030 - <dt>Capabilities</dt>
1031 - <dd>{provider.capabilities.length > 0 ? provider.capabilities.join(", ") : "None"}</dd>
1032 - </div>
1033 - </dl>
1034 - {provider.officialProviderSetupRequired ? (
1035 - <p className="broker-note">{provider.providerStatus === "DOCUMENTATION_REQUIRED" ? "Documentation required" : "Not configured"}</p>
1036 - ) : null}
1037 - {isMock && providerConnections.length === 0 ? (
1038 - <Button variant="secondary" onClick={onConnectDemo} disabled={loading}>
1039 - {loading ? "Connecting..." : "Connect Demo Broker"}
2042 + <p className="broker-message">{message}</p>
2043 + {!partnerUnavailable && errors[provider.brokerType] ? <div className="broker-inline-error" role="alert">{errors[provider.brokerType]}</div> : null}
2044 + {linkedPortfolio ? <p className="broker-linked-portfolio">Portfolio: <strong>{linkedPortfolio.name}</strong></p> : null}
2045 + <div className="broker-actions">
2046 + {!authenticationPending && !partnerUnavailable && !developerIndividualMode && !activeConnection && provider.connectable ? (
2047 + <Button onClick={() => onConnectBroker(provider)} disabled={loading}>
2048 + {loading ? "Connecting..." : provider.brokerType === "IBKR" ? "Connect / Re-authenticate" : "Connect"}
2049 </Button>
2050 ) : null}
1042 - {isMock && providerConnections[0] ? (
1043 - <Button variant="secondary" onClick={() => onSyncConnection(providerConnections[0].connectionId)} disabled={loading}>
1044 - {loading ? "Syncing..." : "Sync connection"}
2051 + {!authenticationPending && !partnerUnavailable && !developerIndividualMode && activeConnection && recoverable ? (
2052 + <Button onClick={() => onAuthenticate(activeConnection.connectionId, provider)} disabled={loading}>
2053 + {loading ? "Opening sign-in..." : activeConnection.brokerType === "IBKR" ? "Connect / Re-authenticate" : "Connect"}
2054 </Button>
2055 ) : null}
2056 + {authenticationPending && !authenticationTimedOut ? (
2057 + <Button variant="ghost" onClick={onCancelAuthentication}>Cancel authentication</Button>
2058 + ) : null}
2059 + {authenticationPending && authenticationTimedOut ? (
2060 + <>
2061 + <Button variant="secondary" onClick={onContinueAuthentication}>Continue checking</Button>
2062 + {activeConnection ? <Button onClick={() => {
2063 + onCancelAuthentication();
2064 + void onAuthenticate(activeConnection.connectionId, provider);
2065 + }}>Try again</Button> : null}
2066 + <Button variant="ghost" onClick={onCancelAuthentication}>Cancel</Button>
2067 + </>
2068 + ) : null}
2069 + {activeConnection && connected ? <Button variant="secondary" disabled>Manage</Button> : null}
2070 + {activeConnection && (!partnerUnavailable || meaningfulPersistedLinkage) ? (
2071 + <Button variant="ghost" onClick={() => onDisconnect(activeConnection.connectionId)} disabled={loading}>Disconnect</Button>
2072 + ) : null}
2073 + {developerIndividualMode ? (
2074 + <Button variant="secondary" onClick={() => setDeveloperProvider(
2075 + developerProvider === provider.brokerType ? null : provider.brokerType
2076 + )} disabled={loading}>Configure DEV API</Button>
2077 + ) : null}
2078 + {provider.manualImportSupported ? (
2079 + <Button variant="secondary" onClick={() => {
2080 + setImportProvider(importProvider === provider.brokerType ? null : provider.brokerType);
2081 + setImportFile(null); setImportPreview(null); setImportError(null); setImportName("");
2082 + }} disabled={loading}>Import portfolio</Button>
2083 + ) : null}
2084 + </div>
2085 + {developerIndividualMode && developerProvider === provider.brokerType ? (
2086 + <form className="broker-developer-form" onSubmit={async (event) => {
2087 + event.preventDefault();
2088 + await onConfigureIndividual(provider, developerKey, developerSecret);
2089 + setDeveloperKey("");
2090 + setDeveloperSecret("");
2091 + setDeveloperProvider(null);
2092 + }}>
2093 + <p>Developer/test individual API setup. Credentials are stored write-only.</p>
2094 + <label>API key<input type="password" autoComplete="off" value={developerKey} onChange={(event) => setDeveloperKey(event.target.value)} required /></label>
2095 + <label>API secret<input type="password" autoComplete="new-password" value={developerSecret} onChange={(event) => setDeveloperSecret(event.target.value)} required /></label>
2096 + <Button type="submit" disabled={loading}>Save DEV credentials</Button>
2097 + </form>
2098 + ) : null}
2099 + {provider.manualImportSupported && importProvider === provider.brokerType ? (
2100 + <div className="import-modal-backdrop" role="presentation">
2101 + <section className="import-modal" role="dialog" aria-modal="true" aria-label={`Import ${provider.displayName} Portfolio`}>
2102 + <header className="import-modal-header"><div><span>Step {importPreview ? "2" : "1"} of 2</span><h2>Import {provider.displayName} Portfolio</h2></div><button className="icon-button" aria-label="Close import" onClick={() => setImportProvider(null)}><X size={20} /></button></header>
2103 + {provider.manualImportParserStatus !== "SUPPORTED" ? (
2104 + <p role="status">This broker portfolio file format is not yet supported. No columns have been guessed.</p>
2105 + ) : (
2106 + <>
2107 + <p className="import-section-label">Select statement</p>
2108 + <label className="import-dropzone" onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
2109 + event.preventDefault(); const selected = event.dataTransfer.files[0] ?? null;
2110 + if (!selected || !selected.name.toLowerCase().endsWith(".csv") || selected.size === 0 || selected.size > 5 * 1024 * 1024) {
2111 + setImportFile(null); setImportError("Choose a non-empty CSV file no larger than 5 MB."); return;
2112 + }
2113 + setImportFile(selected); setImportPreview(null); setImportError(null);
2114 + }}><UploadCloud size={32} /><strong>Drag &amp; drop your CSV here</strong><span>or choose an ICICI/HDFC equity portfolio CSV</span><span className="button button-secondary">Choose CSV file</span><input type="file" accept=".csv,text/csv" onChange={(event) => {
2115 + const selected = event.target.files?.[0] ?? null;
2116 + if (selected && (!selected.name.toLowerCase().endsWith(".csv") || selected.size === 0 || selected.size > 5 * 1024 * 1024)) {
2117 + setImportFile(null); setImportError("Choose a non-empty CSV file no larger than 5 MB."); return;
2118 + }
2119 + setImportFile(selected); setImportPreview(null); setImportError(null);
2120 + }} /></label>
2121 + {importFile ? <p className="import-file"><CheckCircle2 size={18} /> <strong>{importFile.name.replace(/[\\/]/g, "")}</strong></p> : null}
2122 + {importFile && !detectedImportAccount(importFile.name, provider.brokerType) ? <label>Portfolio name<input value={importName} onChange={(event) => setImportName(event.target.value)} /></label> : importFile ? <p>Account detected: <strong>{detectedImportAccount(importFile.name, provider.brokerType)}</strong></p> : null}
2123 + <Button variant="secondary" disabled={!importFile || importBusy} onClick={async () => {
2124 + if (!importFile) return;
2125 + setImportBusy(true); setImportError(null);
2126 + try { setImportPreview(await portfolioApi.previewImport(provider.brokerType, importFile, importName)); }
2127 + catch (error) { setImportError(getApiFailure(error).message); }
2128 + finally { setImportBusy(false); }
2129 + }}>{importBusy ? "Reading..." : "Preview portfolio"}</Button>
2130 + {importError ? <p className="import-error" role="alert">{importError}</p> : null}
2131 + {importPreview ? <div className="import-preview">
2132 + <p><strong>{importPreview.updatesExistingPortfolio ? `Update existing portfolio ${importPreview.portfolioName}` : `Portfolio: ${importPreview.portfolioName}`}</strong></p>
2133 + <p>Broker: {provider.displayName} · Currency: {importPreview.currency}</p>
2134 + {importPreview.statementAt ? <p>Statement date/time: {new Date(importPreview.statementAt).toLocaleString()}</p> : null}
2135 + <p>Rows detected: {importPreview.rowsDetected} · Valid holdings: {importPreview.validHoldings} · Rejected rows: {importPreview.rejectedRows}</p>
2136 + <p>Columns mapped: {importPreview.columnsMapped.join(", ")}</p>
2137 + <div className="import-table-frame"><table className="import-table"><thead><tr><th>Company</th><th>Symbol</th><th>Quantity</th><th>Avg cost</th><th>Statement price</th><th>Market value</th><th>P&amp;L</th></tr></thead><tbody>{importPreview.holdings.map((holding, index) => <tr key={`${holding.symbol}-${index}`}><td>{holding.companyName}</td><td>{holding.symbol}</td><td>{holding.quantity}</td><td>{formatMoney(holding.averageCost, importPreview.currency)}</td><td>{formatMoney(holding.importedPrice, importPreview.currency)}</td><td>{formatMoney(holding.marketValue, importPreview.currency)}</td><td>{formatMoney(holding.unrealizedPnl, importPreview.currency)}</td></tr>)}</tbody></table></div>
2138 + {importPreview.issues.length ? <ul>{importPreview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul> : null}
2139 + <Button disabled={importBusy || importPreview.validHoldings === 0} onClick={async () => {
2140 + if (!importFile) return;
2141 + setImportBusy(true); setImportError(null);
2142 + try {
2143 + const result = await portfolioApi.confirmImport(provider.brokerType, importFile, importName);
2144 + await onImportComplete(result.portfolioId);
2145 + setImportSuccess(true);
2146 + window.setTimeout(() => setImportSuccess(false), 3500);
2147 + setImportProvider(null); setImportFile(null); setImportPreview(null); setImportName("");
2148 + } catch (error) { setImportError(getApiFailure(error).message); }
2149 + finally { setImportBusy(false); }
2150 + }}>{importBusy ? "Importing..." : importPreview.updatesExistingPortfolio ? "Update Portfolio" : "Confirm Import"}</Button>
2151 + </div> : null}
2152 + </>
2153 + )}
2154 + </section></div>
2155 + ) : null}
2156 </div>
1048 - </Card>
1049 - );
2157 + </Card>
2158 + );
2159 })}
1051 - <Card className="wide-panel">
1052 - <EmptyState title="Real broker connections are not enabled" message="Interactive Brokers and ICICI Direct explicitly report provider not configured. The UI never asks for broker passwords." />
1053 - </Card>
1054 - </div>
2160 + </div>{importSuccess ? <div className="success-toast" role="status"><CheckCircle2 size={18} /> Portfolio imported successfully</div> : null}</div>
2161 );
2162 }
2163
2164 +function detectedImportAccount(filename: string, brokerType: string): string | null {
2165 + const clean = filename.replace(/ \(\d+\)(?=\.csv$)/i, "");
2166 + const match = brokerType === "ICICI_DIRECT" ? clean.match(/^(\d+)_PortFolioEqtSummary\.csv$/i)
2167 + : brokerType === "HDFC_SECURITIES" ? clean.match(/^Invest Right Equity Portfolio_(\d+)\.csv$/i) : null;
2168 + return match?.[1] ?? null;
2169 +}
2170 +
2171 +function legacyCompositeCompanyName(value?: string | null): string | null {
2172 + const segments = (value ?? "").split("/").map((segment) => segment.trim());
2173 + return segments.length >= 3 && segments[2] ? segments[2] : null;
2174 +}
2175 +
2176 +function researchCompanyName(position: PortfolioPosition, resolved?: PortfolioResearchCompany): string {
2177 + const instrument = position.instrument;
2178 + const customName = position.customDisplayName?.trim();
2179 + if (customName) return customName;
2180 + const ticker = (resolved?.ticker ?? instrument.ticker).trim();
2181 + const structuredName = instrument.companyName?.trim();
2182 + if (structuredName && structuredName.toUpperCase() !== ticker.toUpperCase()
2183 + && !legacyCompositeCompanyName(structuredName)) return structuredName;
2184 + const trustedName = instrument.canonicalName?.trim() || resolved?.companyName?.trim();
2185 + if (trustedName && trustedName.toUpperCase() !== ticker.toUpperCase()
2186 + && !legacyCompositeCompanyName(trustedName)) return trustedName;
2187 + for (const candidate of [position.displayName, structuredName, trustedName, instrument.brokerDescription]) {
2188 + const parsed = legacyCompositeCompanyName(candidate);
2189 + if (parsed) return parsed;
2190 + }
2191 + return ticker || instrument.brokerSymbol?.trim() || "Unknown company";
2192 +}
2193 +
2194 function ResearchView({
1059 - companies,
2195 + positions,
2196 + selectedPortfolioId,
2197 + portfolioResearchSummary,
2198 + portfolioResearchLoading,
2199 selectedInstrumentId,
2200 onSelectInstrument,
2201 summary,
@@ -1065,9 +2204,13 @@ function ResearchView({
2204 impact,
2205 onEventType,
2206 onImpact,
1068 - onRefresh
2207 + onRefresh,
2208 + onPortfolioRefresh
2209 }: {
1070 - companies: ResearchProfile[];
2210 + positions: PortfolioPosition[];
2211 + selectedPortfolioId: string;
2212 + portfolioResearchSummary: PortfolioResearchSummary | null;
2213 + portfolioResearchLoading: boolean;
2214 selectedInstrumentId: string;
2215 onSelectInstrument: (value: string) => void;
2216 summary: ResearchSummary | null;
@@ -1077,40 +2220,176 @@ function ResearchView({
2220 onEventType: (value: string) => void;
2221 onImpact: (value: string) => void;
2222 onRefresh: () => Promise<void>;
2223 + onPortfolioRefresh: () => Promise<void>;
2224 }) {
2225 + const [openSection, setOpenSection] = useState<ResearchSectionId | null>(null);
2226 + const [openEventId, setOpenEventId] = useState<string | null>(null);
2227 const filteredEvents = (summary?.recentEvents ?? []).filter((event) => {
2228 return (!eventType || event.eventType === eventType) && (!impact || event.impact === impact);
2229 });
2230 const eventTypes = [...new Set((summary?.recentEvents ?? []).map((event) => event.eventType))].sort();
2231 const impacts = [...new Set((summary?.recentEvents ?? []).map((event) => event.impact))].sort();
2232 + const documentsById = new Map((summary?.documents ?? []).map((document) => [document.documentId, document]));
2233 + const researchSections = summary ? getResearchSections(summary, filteredEvents) : [];
2234 + const selectedPortfolioResearchCompany =
2235 + portfolioResearchSummary?.companies.find((company) => company.instrumentId === selectedInstrumentId) ?? null;
2236 + const researchOptions = useMemo(() => {
2237 + const resolvedByHoldingKey = new Map<string, PortfolioResearchCompany>();
2238 + const tickerCounts = new Map<string, number>();
2239 + for (const company of portfolioResearchSummary?.companies ?? []) {
2240 + const ticker = (company.ticker ?? "").toUpperCase();
2241 + if (ticker) {
2242 + tickerCounts.set(ticker, (tickerCounts.get(ticker) ?? 0) + 1);
2243 + }
2244 + }
2245 + for (const company of portfolioResearchSummary?.companies ?? []) {
2246 + const ticker = (company.ticker ?? "").toUpperCase();
2247 + const keys = [
2248 + company.provider && company.providerInstrumentId ? `${company.provider}:${company.providerInstrumentId}` : "",
2249 + company.isin ? `isin:${company.isin}` : "",
2250 + `${company.ticker ?? ""}:${company.exchange ?? ""}`,
2251 + ticker && tickerCounts.get(ticker) === 1 ? `ticker:${ticker}` : ""
2252 + ].filter(Boolean);
2253 + for (const key of keys) {
2254 + resolvedByHoldingKey.set(key.toUpperCase(), company);
2255 + }
2256 + }
2257 +
2258 + return positions.map((position) => {
2259 + const instrument = position.instrument;
2260 + const keys = [
2261 + instrument.provider && instrument.providerInstrumentId ? `${instrument.provider}:${instrument.providerInstrumentId}` : "",
2262 + instrument.isin ? `isin:${instrument.isin}` : "",
2263 + `${instrument.ticker}:${instrument.exchange}`,
2264 + `ticker:${instrument.ticker}`
2265 + ].filter(Boolean);
2266 + const resolved = keys.map((key) => resolvedByHoldingKey.get(key.toUpperCase())).find(Boolean);
2267 + const status = resolved?.status ?? "COMPANY_NOT_RESOLVED";
2268 + const displayTicker = resolved?.ticker ?? instrument.ticker;
2269 + const displayExchange = resolved?.exchange ?? instrument.exchange;
2270 + return {
2271 + value: resolved?.instrumentId ?? instrument.instrumentId,
2272 + companyName: researchCompanyName(position, resolved),
2273 + ticker: displayTicker,
2274 + exchange: displayExchange,
2275 + status,
2276 + loadable: Boolean(resolved?.instrumentId) && isResearchSummaryLoadable(status)
2277 + };
2278 + });
2279 + }, [portfolioResearchSummary, positions]);
2280 + const selectedResearchOption = researchOptions.find((option) => option.value === selectedInstrumentId);
2281 +
2282 + function toggleSection(sectionId: ResearchSectionId) {
2283 + setOpenSection((current) => (current === sectionId ? null : sectionId));
2284 + }
2285 +
2286 + function toggleEvent(eventId: string) {
2287 + setOpenEventId((current) => (current === eventId ? null : eventId));
2288 + }
2289
2290 return (
2291 <div className="research-layout">
2292 <Card className="wide-panel">
2293 + <div className="panel-header">
2294 + <div>
2295 + <h2>Portfolio research</h2>
2296 + <p>Company-level research status across current holdings.</p>
2297 + </div>
2298 + <Button variant="secondary" onClick={onPortfolioRefresh} disabled={portfolioResearchLoading || !selectedPortfolioId}>
2299 + <RefreshCw size={16} />
2300 + {portfolioResearchLoading ? "Refreshing..." : "Refresh portfolio research"}
2301 + </Button>
2302 + </div>
2303 + {portfolioResearchSummary ? (
2304 + <div className="portfolio-research-table" role="table" aria-label="Portfolio research summary">
2305 + <div className="portfolio-research-row portfolio-research-head" role="row">
2306 + <span role="columnheader">Company</span>
2307 + <span role="columnheader">Catalyst</span>
2308 + <span role="columnheader">Confidence</span>
2309 + <span role="columnheader">Evidence</span>
2310 + <span role="columnheader">Sources</span>
2311 + <span role="columnheader">Status</span>
2312 + </div>
2313 + {portfolioResearchSummary.companies.map((company) => {
2314 + const evidenceTotal = Object.keys(company.evidenceCoverage).length || 5;
2315 + const evidenceCount = Object.values(company.evidenceCoverage).filter((value) => value !== "NO_EVIDENCE").length;
2316 + return (
2317 + <button
2318 + className="portfolio-research-row portfolio-research-company"
2319 + key={`${company.instrumentId ?? company.companyName}-${company.status}`}
2320 + role="row"
2321 + type="button"
2322 + onClick={() => {
2323 + if (company.instrumentId) {
2324 + onSelectInstrument(company.instrumentId);
2325 + }
2326 + }}
2327 + >
2328 + <span role="cell">
2329 + <strong>{company.companyName}</strong>
2330 + <small>{[company.ticker, company.exchange, company.isin].filter(Boolean).join(" / ")}</small>
2331 + </span>
2332 + <span role="cell">{company.catalystScore ?? "N/A"}</span>
2333 + <span role="cell">{company.confidence != null ? `${company.confidence}%` : "N/A"}</span>
2334 + <span role="cell">
2335 + {evidenceCount}/{evidenceTotal}
2336 + </span>
2337 + <span role="cell">
2338 + {company.sourceCount} sources / {company.documentCount} docs
2339 + </span>
2340 + <span role="cell">
2341 + <Badge tone={researchStatusTone(company.status)}>{company.status.replaceAll("_", " ")}</Badge>
2342 + <small>{company.lastRefresh ? new Date(company.lastRefresh).toLocaleString() : company.mode}</small>
2343 + </span>
2344 + </button>
2345 + );
2346 + })}
2347 + </div>
2348 + ) : (
2349 + <EmptyState title="No portfolio research run" message="Refresh portfolio research to summarize current holdings." />
2350 + )}
2351 + </Card>
2352 +
2353 + <Card className="wide-panel research-sticky-panel">
2354 <div className="panel-header">
2355 <div>
2356 <h2>Research intelligence</h2>
1093 - <p>Structured evidence, catalyst scoring, and source citations. Final BUY/SELL recommendations are not implemented.</p>
2357 + <p>Structured evidence, catalyst scoring, and source citations.</p>
2358 </div>
2359 <div className="research-actions">
2360 {summary?.demo ? <Badge tone="info">DEMO</Badge> : null}
1097 - <Button variant="secondary" onClick={onRefresh} disabled={loading || !selectedInstrumentId}>
2361 + {summary && !summary.demo ? <Badge tone="positive">LIVE</Badge> : null}
2362 + <Button
2363 + variant="secondary"
2364 + onClick={onRefresh}
2365 + disabled={loading || !selectedInstrumentId || !canRefreshResearch(selectedPortfolioResearchCompany?.status)}
2366 + >
2367 <RefreshCw size={16} />
2368 {loading ? "Refreshing..." : "Refresh research"}
2369 </Button>
2370 </div>
2371 </div>
2372 <div className="research-controls">
1104 - <label className="sort-control">
2373 + <label className="sort-control research-company-control">
2374 <Search size={16} aria-hidden="true" />
2375 <span>Company</span>
1107 - <select value={selectedInstrumentId} onChange={(event) => onSelectInstrument(event.target.value)}>
1108 - {companies.map((company) => (
1109 - <option key={company.instrumentId} value={company.instrumentId}>
1110 - {company.ticker} · {company.exchange} · {company.companyName}
2376 + <select
2377 + className="research-company-select"
2378 + value={selectedInstrumentId}
2379 + title={selectedResearchOption?.companyName}
2380 + onChange={(event) => onSelectInstrument(event.target.value)}
2381 + >
2382 + {researchOptions.map((option) => (
2383 + <option key={`${option.value}-${option.companyName}`} value={option.value} title={option.companyName}>
2384 + {option.companyName}
2385 </option>
2386 ))}
2387 </select>
2388 + {selectedResearchOption ? (
2389 + <small className="research-company-meta">
2390 + {[selectedResearchOption.ticker, selectedResearchOption.exchange].filter(Boolean).join(" · ")}
2391 + </small>
2392 + ) : null}
2393 </label>
2394 <label className="sort-control">
2395 <span>Event</span>
@@ -1155,79 +2434,185 @@ function ResearchView({
2434 <div>
2435 <h2>{summary.profile.companyName}</h2>
2436 <p>
1158 - {summary.profile.ticker} · {summary.profile.exchange} · {summary.profile.country} · {summary.profile.isin ?? "No ISIN"}
2437 + {summary.profile.ticker} / {summary.profile.exchange} / {summary.profile.country} / {summary.profile.isin ?? "No ISIN"}
2438 </p>
2439 </div>
1161 - <Badge tone="neutral">No BUY/SELL rating</Badge>
1162 - </div>
1163 - <div className="research-tabs" role="tablist" aria-label="Research sections">
1164 - {["Overview", "Growth", "Orders & Backlog", "CAPEX & Capacity", "Customers", "Guidance", "News / Events", "Sources"].map((tab) => (
1165 - <span role="tab" aria-selected={tab === "Overview"} key={tab}>
1166 - {tab}
1167 - </span>
1168 - ))}
1169 - </div>
1170 - <div className="score-grid">
1171 - {Object.entries(summary.catalystScore.buckets).map(([label, value]) => (
1172 - <div className="score-row" key={label}>
1173 - <span>{label}</span>
1174 - <strong>{value}</strong>
1175 - <div className="bar-track" aria-hidden="true">
1176 - <span style={{ width: `${value}%` }} />
1177 - </div>
1178 - </div>
1179 - ))}
1180 - </div>
1181 - </Card>
1182 -
1183 - <section className="event-grid" aria-label="Research events">
1184 - {filteredEvents.length === 0 ? (
1185 - <Card className="wide-panel">
1186 - <EmptyState title="No matching events" message="Change the filters or refresh research fixtures." />
1187 - </Card>
1188 - ) : (
1189 - filteredEvents.map((event) => <ResearchEventCard event={event} key={event.eventId} />)
1190 - )}
1191 - </section>
1192 -
1193 - <Card className="wide-panel">
1194 - <div className="panel-header">
1195 - <div>
1196 - <h2>Sources</h2>
1197 - <p>Traceable citations for the extracted facts. Raw page bodies are not displayed.</p>
1198 - </div>
1199 - </div>
1200 - <div className="source-list">
1201 - {summary.documents.map((document) => (
1202 - <a href={document.canonicalUrl} target="_blank" rel="noreferrer" key={document.documentId}>
1203 - <strong>{document.title ?? document.publisher ?? document.sourceName}</strong>
1204 - <span>{document.sourceType} · {document.reliabilityLevel} · {document.publishedAt ? new Date(document.publishedAt).toLocaleDateString() : "No publication date"}</span>
1205 - </a>
1206 - ))}
2440 </div>
2441 + <section className="research-accordion" aria-label="Research detail sections">
2442 + {researchSections.map((section) => {
2443 + const isOpen = openSection === section.id;
2444 + const panelId = `research-section-${section.id}`;
2445 + const buttonId = `research-section-button-${section.id}`;
2446 +
2447 + return (
2448 + <article className="research-accordion-item" key={section.id}>
2449 + <h3>
2450 + <button
2451 + id={buttonId}
2452 + className="research-accordion-button"
2453 + type="button"
2454 + aria-expanded={isOpen}
2455 + aria-controls={panelId}
2456 + onClick={() => toggleSection(section.id)}
2457 + >
2458 + <span>{section.title}</span>
2459 + <span className="research-section-meta">{section.metricLabel}</span>
2460 + <ChevronDown className="accordion-chevron" size={18} aria-hidden="true" />
2461 + </button>
2462 + </h3>
2463 + <div
2464 + id={panelId}
2465 + className="research-accordion-panel"
2466 + role="region"
2467 + aria-labelledby={buttonId}
2468 + hidden={!isOpen}
2469 + >
2470 + {section.id === "overview" ? (
2471 + <ResearchOverview summary={summary} />
2472 + ) : section.id === "news" ? (
2473 + <ResearchEventRows
2474 + events={section.events}
2475 + documentsById={documentsById}
2476 + openEventId={openEventId}
2477 + onToggleEvent={toggleEvent}
2478 + />
2479 + ) : section.id === "sources" ? (
2480 + <ResearchSources documents={summary.documents} />
2481 + ) : section.events.length > 0 ? (
2482 + <div className="research-detail-list">
2483 + {section.events.map((event) => (
2484 + <ResearchEventDetail event={event} document={documentsById.get(event.sourceDocumentId)} key={event.eventId} />
2485 + ))}
2486 + </div>
2487 + ) : (
2488 + <EmptyState title="No evidence in this section" message="No filtered research events currently map to this topic." />
2489 + )}
2490 + </div>
2491 + </article>
2492 + );
2493 + })}
2494 + </section>
2495 </Card>
2496 </>
2497 ) : null}
2498
2499 {!loading && !summary ? (
2500 <Card className="wide-panel">
1214 - <EmptyState title="No research profile loaded" message="The research API is unavailable or has no fixture profiles." />
2501 + <EmptyState
2502 + title={researchEmptyTitle(selectedPortfolioResearchCompany?.status)}
2503 + message={researchEmptyMessage(selectedPortfolioResearchCompany?.status)}
2504 + />
2505 </Card>
2506 ) : null}
2507 </div>
2508 );
2509 }
2510
1221 -function ResearchEventCard({ event }: { event: import("../lib/portfolio-api").ResearchEvent }) {
1222 - const impactTone = event.impact.includes("NEGATIVE") ? "negative" : event.impact.includes("POSITIVE") ? "positive" : event.impact === "UNCERTAIN" ? "warning" : "neutral";
2511 +function ResearchOverview({ summary }: { summary: ResearchSummary }) {
2512 + const categoryRows = summary.catalystScore.categoryEvidence
2513 + ? Object.entries(summary.catalystScore.categoryEvidence).map(([label, evidence]) => [label, evidence.score, evidence.status] as const)
2514 + : Object.entries(summary.catalystScore.buckets).map(([label, value]) => [label, value, value === null ? "NO_EVIDENCE" : "NEUTRAL_EVIDENCE"] as const);
2515 +
2516 + return (
2517 + <div className="research-overview-grid">
2518 + {categoryRows.map(([label, value, status]) => (
2519 + <div className="score-row compact" key={label}>
2520 + <span>{label}</span>
2521 + <strong>{value ?? "N/A"}</strong>
2522 + <div className="bar-track" aria-hidden="true">
2523 + <span style={{ width: `${value ?? 0}%` }} />
2524 + </div>
2525 + <small>{status.replaceAll("_", " ")}</small>
2526 + </div>
2527 + ))}
2528 + <div className="research-summary-note">
2529 + <strong>{summary.demo ? "DEMO research data" : "Live research data"}</strong>
2530 + <span>
2531 + {summary.recentEvents.length} events / {summary.documents.length} documents / generated{" "}
2532 + {new Date(summary.catalystScore.generatedAt).toLocaleDateString()}
2533 + </span>
2534 + </div>
2535 + </div>
2536 + );
2537 +}
2538 +
2539 +function ResearchEventRows({
2540 + events,
2541 + documentsById,
2542 + openEventId,
2543 + onToggleEvent
2544 +}: {
2545 + events: ResearchEvent[];
2546 + documentsById: Map<string, ResearchDocument>;
2547 + openEventId: string | null;
2548 + onToggleEvent: (eventId: string) => void;
2549 +}) {
2550 + if (events.length === 0) {
2551 + return <EmptyState title="No matching events" message="Change the filters or refresh research fixtures." />;
2552 + }
2553 +
2554 return (
1224 - <Card className="research-event-card" as="article">
2555 + <div className="research-event-rows">
2556 + {events.map((event) => {
2557 + const isOpen = openEventId === event.eventId;
2558 + const panelId = `research-event-${event.eventId}`;
2559 + const buttonId = `research-event-button-${event.eventId}`;
2560 +
2561 + return (
2562 + <article className="research-event-row" key={event.eventId}>
2563 + <h4>
2564 + <button
2565 + id={buttonId}
2566 + className="research-event-row-button"
2567 + type="button"
2568 + aria-expanded={isOpen}
2569 + aria-controls={panelId}
2570 + onClick={() => onToggleEvent(event.eventId)}
2571 + >
2572 + <span>
2573 + <Badge tone={impactTone(event.impact)}>{event.impact.replaceAll("_", " ")}</Badge>
2574 + <strong>{event.title}</strong>
2575 + </span>
2576 + <span>{event.eventType.replaceAll("_", " ")}</span>
2577 + <ChevronDown className="accordion-chevron" size={16} aria-hidden="true" />
2578 + </button>
2579 + </h4>
2580 + <div id={panelId} role="region" aria-labelledby={buttonId} hidden={!isOpen}>
2581 + <ResearchEventDetail event={event} document={documentsById.get(event.sourceDocumentId)} />
2582 + </div>
2583 + </article>
2584 + );
2585 + })}
2586 + </div>
2587 + );
2588 +}
2589 +
2590 +function ResearchEventDetail({ event, document }: { event: ResearchEvent; document?: ResearchDocument }) {
2591 + const supportingSources = event.supportingSources?.length
2592 + ? event.supportingSources
2593 + : [
2594 + {
2595 + publisher: document?.publisher ?? document?.sourceName ?? event.sourceType,
2596 + url: event.sourceUrl,
2597 + sourceType: event.sourceClassification ?? document?.sourceClassification ?? event.sourceType,
2598 + publishedAt: event.publishedAt ?? event.eventDate ?? document?.publishedAt ?? null,
2599 + retrievedAt: event.retrievedAt ?? document?.retrievedAt ?? event.detectedAt,
2600 + reliability: event.reliability,
2601 + sourceMode: event.sourceMode,
2602 + documentId: event.sourceDocumentId,
2603 + sourceName: document?.sourceName ?? event.sourceType,
2604 + canonicalUrl: event.sourceUrl,
2605 + independent: true
2606 + }
2607 + ];
2608 + return (
2609 + <div className="research-event-detail">
2610 <div className="panel-header compact">
2611 <div>
2612 <Badge tone="neutral">{event.eventType.replaceAll("_", " ")}</Badge>
1228 - <h2>{event.title}</h2>
2613 + <h4>{event.title}</h4>
2614 </div>
1230 - <Badge tone={impactTone}>{event.impact.replaceAll("_", " ")}</Badge>
2615 + <Badge tone={impactTone(event.impact)}>{event.impact.replaceAll("_", " ")}</Badge>
2616 </div>
2617 <div className="event-value-line">
2618 <strong>{event.monetaryOriginal ?? (event.capacityValue ? `${event.capacityValue} ${event.capacityUnit}` : event.percentageOriginal ?? "Value undisclosed")}</strong>
@@ -1249,15 +2634,169 @@ function ResearchEventCard({ event }: { event: import("../lib/portfolio-api").Re
2634 </div>
2635 <div>
2636 <dt>Source</dt>
1252 - <dd>{event.sourceType}</dd>
2637 + <dd>{document?.sourceName ?? event.sourceClassification ?? event.sourceType}</dd>
2638 + </div>
2639 + <div>
2640 + <dt>Horizon</dt>
2641 + <dd>{event.timeHorizon.replaceAll("_", " ")}</dd>
2642 </div>
2643 </dl>
2644 <blockquote>{event.rawEvidenceReference}</blockquote>
1256 - <a className="source-link" href={event.sourceUrl} target="_blank" rel="noreferrer">
1257 - Open source
1258 - </a>
1259 - </Card>
2645 + <div className="supporting-sources">
2646 + <strong>Supporting sources</strong>
2647 + {supportingSources.map((source) => (
2648 + <a className="source-link" href={source.url} target="_blank" rel="noreferrer" key={`${event.eventId}-${source.documentId}`}>
2649 + <span>{source.publisher ?? source.sourceName}</span>
2650 + <small>
2651 + {source.sourceMode} / {source.sourceType.replaceAll("_", " ")} / {source.reliability} /{" "}
2652 + {source.publishedAt ? new Date(source.publishedAt).toLocaleDateString() : "No publication date"} /{" "}
2653 + {source.independent ? "Independent" : "Duplicate"}
2654 + </small>
2655 + </a>
2656 + ))}
2657 + </div>
2658 + </div>
2659 + );
2660 +}
2661 +
2662 +function ResearchSources({ documents }: { documents: ResearchDocument[] }) {
2663 + if (documents.length === 0) {
2664 + return <EmptyState title="No sources" message="No source documents are available for this research profile." />;
2665 + }
2666 +
2667 + return (
2668 + <div className="source-list">
2669 + {documents.map((document) => (
2670 + <a href={document.canonicalUrl} target="_blank" rel="noreferrer" key={document.documentId}>
2671 + <strong>{document.title ?? document.publisher ?? document.sourceName}</strong>
2672 + <span>
2673 + {document.sourceMode} / {(document.sourceClassification ?? document.sourceType).replaceAll("_", " ")} / {document.reliabilityLevel} /{" "}
2674 + {document.publishedAt ? new Date(document.publishedAt).toLocaleDateString() : "No publication date"}
2675 + </span>
2676 + <small>
2677 + {document.sourceName} / {document.status} / retrieved {new Date(document.retrievedAt).toLocaleDateString()} / hash{" "}
2678 + {document.contentHash.slice(0, 12)}
2679 + </small>
2680 + </a>
2681 + ))}
2682 + </div>
2683 + );
2684 +}
2685 +
2686 +function getResearchSections(summary: ResearchSummary, filteredEvents: ResearchEvent[]) {
2687 + const orders = eventsMatching(filteredEvents, ["NEW_ORDER", "ORDER_BACKLOG_CHANGE", "MAJOR_CONTRACT", "GOVERNMENT_CONTRACT"]);
2688 + const capex = eventsMatching(filteredEvents, ["CAPEX", "CAPACITY_EXPANSION", "NEW_FACILITY", "FACTORY_EXPANSION", "PROJECT_DELAY"]);
2689 + const customers = eventsMatching(filteredEvents, ["NEW_CUSTOMER", "CUSTOMER_EXPANSION", "MAJOR_CUSTOMER", "CUSTOMER_LOSS"]);
2690 + const guidance = eventsMatching(filteredEvents, ["GUIDANCE_RAISED", "GUIDANCE_LOWERED", "GUIDANCE_CUT", "GUIDANCE_MAINTAINED", "REVENUE_GUIDANCE", "MARGIN_GUIDANCE"]);
2691 + const growth = filteredEvents.filter((event) =>
2692 + ["GEOGRAPHIC_EXPANSION", "PARTNERSHIP", "PRODUCT_LAUNCH"].includes(event.eventType)
2693 );
2694 +
2695 + return [
2696 + { id: "overview" as const, title: "Overview", metricLabel: `Score ${summary.catalystScore.overallScore}`, events: filteredEvents },
2697 + { id: "growth" as const, title: "Growth", metricLabel: categoryMetricLabel(summary, "Growth"), events: growth },
2698 + { id: "orders" as const, title: "Orders & Backlog", metricLabel: categoryMetricLabel(summary, "Orders & Backlog"), events: orders },
2699 + { id: "capex" as const, title: "CAPEX & Capacity", metricLabel: categoryMetricLabel(summary, "CAPEX & Capacity"), events: capex },
2700 + { id: "customers" as const, title: "Customers", metricLabel: categoryMetricLabel(summary, "Customers"), events: customers },
2701 + { id: "guidance" as const, title: "Guidance", metricLabel: categoryMetricLabel(summary, "Guidance"), events: guidance },
2702 + { id: "news" as const, title: "News / Events", metricLabel: `Events ${filteredEvents.length}`, events: filteredEvents },
2703 + { id: "sources" as const, title: "Sources", metricLabel: `Documents ${summary.documents.length}`, events: [] }
2704 + ];
2705 +}
2706 +
2707 +function categoryMetricLabel(summary: ResearchSummary, category: string) {
2708 + const evidence = summary.catalystScore.categoryEvidence?.[category];
2709 + if (evidence?.status === "NO_EVIDENCE" || evidence?.score == null) {
2710 + return "N/A / No evidence";
2711 + }
2712 + const count = evidence.independentSourceCount ?? evidence.sourceCount;
2713 + const sources = count === 1 ? "1 source" : `${count} sources`;
2714 + if (evidence.hasConflict || evidence.status === "MIXED_EVIDENCE") {
2715 + return `Mixed evidence / Score ${evidence.score} / ${sources}`;
2716 + }
2717 + return `Score ${evidence.score} / ${sources}`;
2718 +}
2719 +
2720 +function eventsMatching(events: ResearchEvent[], eventTypes: string[]) {
2721 + return events.filter((event) => eventTypes.includes(event.eventType));
2722 +}
2723 +
2724 +function impactTone(impact: string): "neutral" | "positive" | "negative" | "warning" | "info" {
2725 + if (impact.includes("NEGATIVE")) {
2726 + return "negative";
2727 + }
2728 + if (impact.includes("POSITIVE")) {
2729 + return "positive";
2730 + }
2731 + if (impact === "UNCERTAIN") {
2732 + return "warning";
2733 + }
2734 + return "neutral";
2735 +}
2736 +
2737 +function researchStatusTone(status: string): "neutral" | "positive" | "negative" | "warning" | "info" {
2738 + if (status === "AVAILABLE") {
2739 + return "positive";
2740 + }
2741 + if (status === "DEGRADED" || status === "RESEARCH_NOT_REFRESHED" || status === "NO_EVIDENCE") {
2742 + return "warning";
2743 + }
2744 + if (status === "COMPANY_NOT_RESOLVED" || status === "RESEARCH_PROVIDER_UNAVAILABLE") {
2745 + return "negative";
2746 + }
2747 + if (status === "RESEARCH_NOT_APPLICABLE") {
2748 + return "info";
2749 + }
2750 + if (status?.startsWith("ETF_RESEARCH_")) {
2751 + return status === "ETF_RESEARCH_AVAILABLE" ? "positive" : "info";
2752 + }
2753 + return "neutral";
2754 +}
2755 +
2756 +function isResearchSummaryLoadable(status?: string | null) {
2757 + return status === "AVAILABLE" || status === "DEGRADED";
2758 +}
2759 +
2760 +function canRefreshResearch(status?: string | null) {
2761 + return Boolean(status) && !status?.startsWith("ETF_RESEARCH_") && !["COMPANY_NOT_RESOLVED", "RESEARCH_NOT_APPLICABLE"].includes(status ?? "");
2762 +}
2763 +
2764 +function researchEmptyTitle(status?: string | null) {
2765 + if (status === "ETF_RESEARCH_NOT_REFRESHED") {
2766 + return "ETF research not refreshed";
2767 + }
2768 + if (status === "ETF_RESEARCH_SOURCE_UNAVAILABLE") {
2769 + return "ETF research unavailable";
2770 + }
2771 + if (status === "RESEARCH_NOT_APPLICABLE") {
2772 + return "Research not applicable";
2773 + }
2774 + if (status === "COMPANY_NOT_RESOLVED") {
2775 + return "Company not resolved";
2776 + }
2777 + if (status === "RESEARCH_NOT_REFRESHED") {
2778 + return "Research not refreshed";
2779 + }
2780 + return "Research unavailable";
2781 +}
2782 +
2783 +function researchEmptyMessage(status?: string | null) {
2784 + if (status === "ETF_RESEARCH_NOT_REFRESHED") {
2785 + return "No shared public ETF research has been collected for this fund yet.";
2786 + }
2787 + if (status === "ETF_RESEARCH_SOURCE_UNAVAILABLE") {
2788 + return "No acceptable public ETF source was collected during the last refresh.";
2789 + }
2790 + if (status === "RESEARCH_NOT_APPLICABLE") {
2791 + return "Company-level catalyst research is not applicable for this asset type.";
2792 + }
2793 + if (status === "COMPANY_NOT_RESOLVED") {
2794 + return "This holding did not match a canonical research company.";
2795 + }
2796 + if (status === "RESEARCH_NOT_REFRESHED") {
2797 + return "No shared public research has been collected for this company yet.";
2798 + }
2799 + return "Select a holding with available company research.";
2800 }
2801
2802 function SettingsView() {
@@ -1281,7 +2820,7 @@ function SettingsView() {
2820 </div>
2821 <div>
2822 <h3>Risk indicators</h3>
1284 - <p>Portfolio risk presentation is prepared, but risk scoring is not implemented in Phase 2B.</p>
2823 + <p>Portfolio risk scoring is not yet available.</p>
2824 </div>
2825 <div>
2826 <h3>Recommendation states</h3>
services/broker-service/src/main/java/com/aiinvestment/broker/application/BrokerConnectionService.java
+531 -28
@@ -1,92 +1,348 @@
1 -package com.aiinvestment.broker.application;
1 +package com.aiinvestment.broker.application;
2
3 import com.aiinvestment.broker.domain.BrokerConnection;
4 import com.aiinvestment.broker.audit.BrokerOperationAuditor;
5 -import com.aiinvestment.broker.persistence.BrokerConnectionEntity;
6 -import com.aiinvestment.broker.persistence.BrokerConnectionMapper;
7 -import com.aiinvestment.broker.persistence.BrokerConnectionRepository;
5 +import com.aiinvestment.broker.api.BrokerConnectionResponse;
6 +import com.aiinvestment.broker.api.BrokerConnectorLoginResponse;
7 +import com.aiinvestment.broker.api.BrokerConnectorResponse;
8 +import com.aiinvestment.broker.api.BrokerSnapshotResponse;
9 +import com.aiinvestment.broker.api.BrokerAuthenticationActionResponse;
10 +import com.aiinvestment.broker.api.BrokerProviderResponse;
11 +import com.aiinvestment.broker.connector.BrokerConnector;
12 +import com.aiinvestment.broker.connector.BrokerConnectorState;
13 +import com.aiinvestment.broker.connector.ConnectorRuntimeMode;
14 +import com.aiinvestment.broker.config.IBKRProviderProperties;
15 +import com.aiinvestment.broker.provider.icici.ICICIDirectBrokerProvider;
16 +import com.aiinvestment.broker.provider.hdfc.HDFCSecuritiesBrokerProvider;
17 +import com.aiinvestment.broker.persistence.*;
18 +import com.aiinvestment.broker.partner.PartnerAuthProvider;
19 import com.aiinvestment.broker.resilience.ProviderCircuitBreaker;
20 import com.aiinvestment.broker.resilience.ProviderRateLimiter;
21 import com.aiinvestment.shared.domain.broker.*;
22 import org.springframework.stereotype.Service;
23 import org.springframework.transaction.annotation.Transactional;
24 +import org.slf4j.Logger;
25 +import org.slf4j.LoggerFactory;
26
27 import java.time.Instant;
28 +import java.util.Comparator;
29 import java.util.List;
30 import java.util.UUID;
31 +import java.util.Optional;
32 +import java.util.stream.Collectors;
33
34 @Service
35 public class BrokerConnectionService {
20 - public static final UUID DEFAULT_PHASE_USER = UUID.fromString("00000000-0000-0000-0000-000000000001");
21 -
36 + private static final Logger logger = LoggerFactory.getLogger(BrokerConnectionService.class);
37 private final BrokerConnectionRepository repository;
38 private final List<BrokerProvider> providers;
39 private final ProviderRateLimiter rateLimiter;
40 private final ProviderCircuitBreaker circuitBreaker;
41 private final BrokerOperationAuditor auditor;
42 + private final BrokerConnectorInstanceRepository connectorRepository;
43 + private final List<BrokerConnector> connectors;
44 + private final IBKRProviderProperties ibkrProperties;
45 + private final List<PartnerAuthProvider> partnerAuthProviders;
46 + private final CanonicalBrokerConnectionResolver canonicalConnectionResolver;
47
48 public BrokerConnectionService(BrokerConnectionRepository repository, List<BrokerProvider> providers,
49 ProviderRateLimiter rateLimiter, ProviderCircuitBreaker circuitBreaker,
30 - BrokerOperationAuditor auditor) {
50 + BrokerOperationAuditor auditor,
51 + BrokerConnectorInstanceRepository connectorRepository,
52 + List<BrokerConnector> connectors,
53 + IBKRProviderProperties ibkrProperties,
54 + List<PartnerAuthProvider> partnerAuthProviders,
55 + CanonicalBrokerConnectionResolver canonicalConnectionResolver) {
56 this.repository = repository;
57 this.providers = providers;
58 this.rateLimiter = rateLimiter;
59 this.circuitBreaker = circuitBreaker;
60 this.auditor = auditor;
61 + this.connectorRepository = connectorRepository;
62 + this.connectors = connectors;
63 + this.ibkrProperties = ibkrProperties;
64 + this.partnerAuthProviders = partnerAuthProviders;
65 + this.canonicalConnectionResolver = canonicalConnectionResolver;
66 }
67
68 @Transactional(readOnly = true)
39 - public List<BrokerConnection> listConnections() {
40 - return repository.findByUserId(DEFAULT_PHASE_USER).stream().map(BrokerConnectionMapper::toDomain).toList();
69 + public List<BrokerConnection> listConnections(UUID userId) {
70 + return canonicalConnectionResolver.resolveAll(repository.findByUserId(userId)).stream()
71 + .map(BrokerConnectionMapper::toDomain).toList();
72 }
73
74 @Transactional(readOnly = true)
44 - public BrokerConnection getConnection(UUID connectionId) {
45 - return repository.findById(connectionId).map(BrokerConnectionMapper::toDomain)
75 + public BrokerConnection getConnection(UUID userId, UUID connectionId) {
76 + return repository.findByConnectionIdAndUserId(connectionId, userId).map(BrokerConnectionMapper::toDomain)
77 .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
78 }
79
80 @Transactional
50 - public BrokerConnection connectMock() {
81 + public BrokerConnection connectMock(UUID userId) {
82 Instant now = Instant.now();
52 - BrokerConnectionEntity entity = new BrokerConnectionEntity(UUID.randomUUID(), DEFAULT_PHASE_USER, BrokerType.MOCK,
83 + BrokerConnectionEntity entity = new BrokerConnectionEntity(UUID.randomUUID(), userId, BrokerType.MOCK,
84 "mock-demo", "Demo Broker", BrokerConnectionState.CONNECTED, now, null, null, null, now, now);
85 return BrokerConnectionMapper.toDomain(repository.save(entity));
86 }
87
88 @Transactional
58 - public BrokerConnection initiateConnection(BrokerType brokerType) {
89 + public BrokerConnection initiateConnection(UUID userId, BrokerType brokerType) {
90 Instant startedAt = Instant.now();
91 if (brokerType == BrokerType.MOCK) {
61 - BrokerConnection connection = connectMock();
92 + BrokerConnection connection = connectMock(userId);
93 auditor.record(brokerType, "connect", startedAt, true, "LOCAL");
94 return connection;
95 }
96 + BrokerConnectionEntity existing = canonicalConnectionResolver
97 + .resolve(repository.findByUserId(userId), brokerType).orElse(null);
98 + if (existing != null) {
99 + return BrokerConnectionMapper.toDomain(existing);
100 + }
101 + if (brokerType == BrokerType.IBKR) {
102 + BrokerConnector connector = connectorFor(BrokerType.IBKR);
103 + BrokerConnectorInstanceEntity connectorEntity = createConnectorInstance(userId, connector);
104 + var connectorStatus = connector.status(userId, connectorEntity.getConnectorId());
105 + updateConnectorStatus(connectorEntity, connectorStatus.runtimeStatus(), connectorStatus.authStatus());
106 + String loginUrl = connectorStatus.loginUrl() == null
107 + ? connectorEntity.getLoginUrl()
108 + : connectorStatus.loginUrl();
109 + if (loginUrl == null) {
110 + loginUrl = connector.loginUrl(userId, connectorEntity.getConnectorId());
111 + }
112 + connectorEntity.setLoginUrl(loginUrl);
113 + if (!connectorStatus.authenticated()) {
114 + BrokerConnectionEntity entity = new BrokerConnectionEntity(UUID.randomUUID(), userId, BrokerType.IBKR,
115 + connectorEntity.getConnectorId(), null, "Interactive Brokers", BrokerConnectionState.AUTHENTICATION_REQUIRED,
116 + null, BrokerProviderStatus.AUTHENTICATION_REQUIRED.name(), "UNAVAILABLE", null,
117 + capabilities(providerFor(BrokerType.IBKR)), null, null, null, connectorStatus.code(), startedAt, startedAt);
118 + auditor.record(brokerType, "connect", startedAt, false, connectorStatus.code());
119 + return BrokerConnectionMapper.toDomain(repository.save(entity));
120 + }
121 + BrokerAccount account = connector.fetchAccounts(userId, connectorEntity.getConnectorId()).stream()
122 + .min(Comparator.comparing(BrokerAccount::brokerAccountId))
123 + .orElseThrow(BrokerProviderException::accountNotFound);
124 + BrokerConnectionEntity entity = new BrokerConnectionEntity(UUID.randomUUID(), userId, BrokerType.IBKR,
125 + connectorEntity.getConnectorId(), account.externalAccountReference(), account.displayName(), BrokerConnectionState.CONNECTED,
126 + account.baseCurrency(), BrokerProviderStatus.CONNECTED.name(), "REAL_BROKER", "client-portal-gateway",
127 + capabilities(providerFor(BrokerType.IBKR)), startedAt, null, null, null, startedAt, startedAt);
128 + auditor.record(brokerType, "connect", startedAt, true, "CONNECTED");
129 + return BrokerConnectionMapper.toDomain(repository.save(entity));
130 + }
131 + if (brokerType == BrokerType.ICICI_DIRECT || brokerType == BrokerType.HDFC_SECURITIES) {
132 + BrokerConnectionStatus providerStatus = providerFor(brokerType).connectionStatus(userId,
133 + new UUID(0L, 0L));
134 + if (providerStatus.providerStatus() != BrokerProviderStatus.AUTHENTICATION_REQUIRED) {
135 + auditor.record(brokerType, "connect", startedAt, false, providerStatus.code());
136 + throw new IllegalStateException(providerStatus.code() + ": " + providerStatus.message());
137 + }
138 + UUID connectionId = UUID.randomUUID();
139 + String displayName = brokerType == BrokerType.ICICI_DIRECT ? "ICICI Direct" : "HDFC Securities";
140 + BrokerConnectionEntity entity = new BrokerConnectionEntity(connectionId, userId, brokerType,
141 + null, displayName, BrokerConnectionState.AUTHENTICATION_REQUIRED,
142 + null, BrokerProviderStatus.AUTHENTICATION_REQUIRED.name(), "UNAVAILABLE", null,
143 + capabilities(providerFor(brokerType)), null, null, null, providerStatus.code(), startedAt, startedAt);
144 + auditor.record(brokerType, "connect", startedAt, true, "AUTHENTICATION_REQUIRED");
145 + return BrokerConnectionMapper.toDomain(repository.save(entity));
146 + }
147 BrokerConnectionStatus status = providerFor(brokerType).connectionStatus();
148 auditor.record(brokerType, "connect", startedAt, false, status.code());
149 + if (brokerType == BrokerType.IBKR && status.providerStatus() == BrokerProviderStatus.AUTHENTICATION_REQUIRED) {
150 + throw BrokerProviderException.authenticationRequired();
151 + }
152 throw new IllegalStateException(status.code() + ": " + status.message());
153 }
154
70 - @Transactional(readOnly = true)
71 - public BrokerConnection status(UUID connectionId) {
72 - return getConnection(connectionId);
155 + @Transactional
156 + public BrokerConnection status(UUID userId, UUID connectionId) {
157 + BrokerConnectionEntity entity = repository.findByConnectionIdAndUserId(connectionId, userId)
158 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
159 + if (entity.getBrokerType() == BrokerType.IBKR && entity.getConnectorId() != null) {
160 + BrokerConnectionState brokerStateBefore = entity.getStatus();
161 + BrokerConnectorInstanceEntity connectorEntity = requireConnector(userId, entity);
162 + var status = connectorFor(BrokerType.IBKR).status(userId, connectorEntity.getConnectorId());
163 + updateConnectorStatus(connectorEntity, status.runtimeStatus(), status.authStatus());
164 + reconcileIbkrConnectionStatus(entity, status);
165 + logger.info("ibkr_auth_status_reconciliation connection={} runtimeState={} authenticated={} "
166 + + "brokerStateBefore={} brokerStateAfter={}",
167 + entity.getConnectionId(), status.runtimeStatus(), status.authenticated(),
168 + brokerStateBefore, entity.getStatus());
169 + } else if (entity.getBrokerType() == BrokerType.ICICI_DIRECT || entity.getBrokerType() == BrokerType.HDFC_SECURITIES) {
170 + reconcileProviderStatus(entity, providerFor(entity.getBrokerType())
171 + .connectionStatus(userId, connectionId));
172 + }
173 + return BrokerConnectionMapper.toDomain(entity);
174 }
175
176 @Transactional
76 - public BrokerConnection refresh(UUID connectionId) {
77 - BrokerConnectionEntity entity = repository.findById(connectionId)
177 + public BrokerAuthenticationActionResponse authenticationAction(UUID userId, UUID connectionId) {
178 + BrokerConnectionEntity entity = repository.findByConnectionIdAndUserId(connectionId, userId)
179 .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
79 - if (entity.getBrokerType() != BrokerType.MOCK) {
80 - throw new IllegalStateException("Refresh is unavailable until official provider authentication is configured.");
180 + BrokerProvider provider = providerFor(entity.getBrokerType());
181 + if (entity.getBrokerType() == BrokerType.IBKR) {
182 + BrokerConnectorInstanceEntity connectorEntity = requireConnector(userId, entity);
183 + BrokerConnector connector = connectorFor(BrokerType.IBKR);
184 + try {
185 + // This endpoint represents an explicit Connect/Re-authenticate action. Unlike ordinary status reads,
186 + // start() idempotently ensures that an ephemeral runtime exists for the persisted connector ID.
187 + var connectorStatus = connector.status(userId, connectorEntity.getConnectorId());
188 +
189 + // Only start the connector if it's not configured or in error state.
190 + // If already running, preserve the existing session to avoid regenerating login URLs mid-MFA.
191 + if (connectorStatus.runtimeStatus() == BrokerConnectorState.NOT_CONFIGURED
192 + || connectorStatus.runtimeStatus() == BrokerConnectorState.ERROR) {
193 + connector.start(userId, connectorEntity.getConnectorId());
194 + connectorStatus = connector.status(userId, connectorEntity.getConnectorId());
195 + }
196 + updateConnectorStatus(connectorEntity, connectorStatus.runtimeStatus(), connectorStatus.authStatus());
197 + reconcileIbkrConnectionStatus(entity, connectorStatus);
198 + String url = null;
199 + if (!connectorStatus.authenticated()) {
200 + url = connectorStatus.loginUrl();
201 + if (url == null || url.isBlank()) {
202 + url = connector.loginUrl(userId, connectorEntity.getConnectorId());
203 + }
204 + if (url == null || url.isBlank()) {
205 + throw BrokerProviderException.unavailable();
206 + }
207 + }
208 + connectorEntity.setLoginUrl(url);
209 + return new BrokerAuthenticationActionResponse(connectionId, BrokerType.IBKR.name(),
210 + entity.getStatus().name(),
211 + connectorStatus.authenticated() ? "NONE" : "REDIRECT_REQUIRED", url,
212 + connectorStatus.authenticated() ? "Broker connection is ready" : "Complete authentication with Interactive Brokers");
213 + } catch (RuntimeException exception) {
214 + connectorEntity.setRuntimeStatus(BrokerConnectorState.ERROR);
215 + connectorEntity.setUpdatedAt(Instant.now());
216 + entity.setStatus(BrokerConnectionState.ERROR);
217 + entity.setProviderStatus(BrokerProviderStatus.ERROR.name());
218 + entity.setLastErrorCode(exception instanceof BrokerProviderException providerException
219 + ? providerException.code() : "BROKER_UNAVAILABLE");
220 + entity.setUpdatedAt(Instant.now());
221 + return new BrokerAuthenticationActionResponse(connectionId, BrokerType.IBKR.name(),
222 + BrokerConnectionState.ERROR.name(), "UNAVAILABLE", null,
223 + "Interactive Brokers authentication is temporarily unavailable. Your saved portfolio is unchanged.");
224 + }
225 + }
226 + if (entity.getBrokerType() == BrokerType.ICICI_DIRECT) {
227 + var status = provider.connectionStatus(userId, connectionId);
228 + reconcileProviderStatus(entity, status);
229 + if (status.providerStatus() != BrokerProviderStatus.CONNECTED) {
230 + var partner = partnerAuthProvider(BrokerType.ICICI_DIRECT);
231 + if (partner.isEmpty()) {
232 + return new BrokerAuthenticationActionResponse(connectionId, BrokerType.ICICI_DIRECT.name(),
233 + entity.getStatus().name(), "PARTNER_AUTH_UNAVAILABLE", null,
234 + "ICICI Direct customer authorization requires broker partner access and is not available yet.");
235 + }
236 + var authorization = partner.get().beginAuthorization(userId, connectionId);
237 + return new BrokerAuthenticationActionResponse(connectionId, BrokerType.ICICI_DIRECT.name(),
238 + "CALLBACK_PENDING", "REDIRECT_REQUIRED", authorization.authenticationUrl(),
239 + "Complete authentication on the official ICICI Direct page.");
240 + }
241 + return new BrokerAuthenticationActionResponse(connectionId, BrokerType.ICICI_DIRECT.name(),
242 + entity.getStatus().name(),
243 + "NONE", null, "Broker connection is ready");
244 + }
245 + if (entity.getBrokerType() == BrokerType.HDFC_SECURITIES) {
246 + var status = provider.connectionStatus(userId, connectionId);
247 + reconcileProviderStatus(entity, status);
248 + if (status.providerStatus() != BrokerProviderStatus.CONNECTED) {
249 + var partner = partnerAuthProvider(BrokerType.HDFC_SECURITIES);
250 + if (partner.isEmpty()) {
251 + return new BrokerAuthenticationActionResponse(connectionId, BrokerType.HDFC_SECURITIES.name(),
252 + entity.getStatus().name(), "PARTNER_AUTH_UNAVAILABLE", null,
253 + "HDFC Securities customer authorization requires broker partner access and is not available yet.");
254 + }
255 + var authorization = partner.get().beginAuthorization(userId, connectionId);
256 + return new BrokerAuthenticationActionResponse(connectionId, BrokerType.HDFC_SECURITIES.name(),
257 + "CALLBACK_PENDING", "REDIRECT_REQUIRED", authorization.authenticationUrl(),
258 + "Complete authentication on the official HDFC Securities page.");
259 + }
260 + return new BrokerAuthenticationActionResponse(connectionId, BrokerType.HDFC_SECURITIES.name(),
261 + entity.getStatus().name(), "NONE", null, "Broker connection is ready");
262 }
263 + return new BrokerAuthenticationActionResponse(connectionId, entity.getBrokerType().name(),
264 + entity.getStatus().name(), "NONE", null, "Connection is ready");
265 + }
266 +
267 + @Transactional
268 + public com.aiinvestment.broker.api.ICICIDirectLoginResponse iciciLogin(UUID userId, UUID connectionId) {
269 + BrokerConnectionEntity entity = requireIciciConnection(userId, connectionId);
270 + ICICIDirectBrokerProvider provider = iciciProvider();
271 + var login = provider.login(userId, connectionId);
272 + entity.setStatus(BrokerConnectionState.AUTHENTICATION_REQUIRED);
273 + entity.setProviderStatus(BrokerProviderStatus.AUTHENTICATION_REQUIRED.name());
274 + entity.setLastErrorCode("AUTHENTICATION_REQUIRED");
275 entity.setUpdatedAt(Instant.now());
276 + return new com.aiinvestment.broker.api.ICICIDirectLoginResponse(connectionId, login.loginUrl(),
277 + BrokerProviderStatus.AUTHENTICATION_REQUIRED.name());
278 + }
279 +
280 + @Transactional
281 + public BrokerConnection attachIciciSession(UUID userId, UUID connectionId, String apiSession) {
282 + BrokerConnectionEntity entity = requireIciciConnection(userId, connectionId);
283 + BrokerConnectionStatus status = iciciProvider().attachApiSession(userId, connectionId, apiSession);
284 + reconcileProviderStatus(entity, status);
285 + entity.setSessionReference("breeze-interactive-session");
286 + entity.setCapabilities(capabilities(iciciProvider(), userId, connectionId));
287 return BrokerConnectionMapper.toDomain(entity);
288 }
289
290 @Transactional
87 - public BrokerConnection sync(UUID connectionId) {
291 + public BrokerConnection attachHdfcRequestToken(UUID userId, UUID connectionId, String requestToken) {
292 + BrokerConnectionEntity entity = requireProviderConnection(userId, connectionId, BrokerType.HDFC_SECURITIES);
293 + BrokerConnectionStatus status = hdfcProvider().attachRequestToken(userId, connectionId, requestToken);
294 + reconcileProviderStatus(entity, status);
295 + entity.setSessionReference("investright-access-token");
296 + entity.setCapabilities(capabilities(hdfcProvider(), userId, connectionId));
297 + return BrokerConnectionMapper.toDomain(entity);
298 + }
299 +
300 + @Transactional
301 + public BrokerConnectorResponse connectorStatus(UUID userId, UUID connectorId) {
302 + BrokerConnectorInstanceEntity entity = connectorRepository.findByConnectorIdAndUserId(connectorId, userId)
303 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectorId));
304 + BrokerConnector connector = connectorFor(entity.getBrokerType());
305 + var status = connector.status(userId, connectorId);
306 + updateConnectorStatus(entity, status.runtimeStatus(), status.authStatus());
307 + return BrokerConnectorResponse.from(entity);
308 + }
309 +
310 + @Transactional
311 + public BrokerConnectorLoginResponse connectorLogin(UUID userId, UUID connectorId) {
312 + BrokerConnectorInstanceEntity entity = connectorRepository.findByConnectorIdAndUserId(connectorId, userId)
313 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectorId));
314 + BrokerConnector connector = connectorFor(entity.getBrokerType());
315 + var status = connector.status(userId, connectorId);
316 + updateConnectorStatus(entity, status.runtimeStatus(), status.authStatus());
317 + String loginUrl = connector.loginUrl(userId, connectorId);
318 + entity.setLoginUrl(loginUrl);
319 + return new BrokerConnectorLoginResponse(connectorId, loginUrl, entity.getAuthStatus().name());
320 + }
321 +
322 + @Transactional
323 + public BrokerConnection refresh(UUID userId, UUID connectionId) {
324 + BrokerConnectionEntity entity = repository.findByConnectionIdAndUserId(connectionId, userId)
325 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
326 + if (entity.getBrokerType() == BrokerType.IBKR) {
327 + BrokerConnectorInstanceEntity connectorEntity = requireConnector(userId, entity);
328 + var status = connectorFor(BrokerType.IBKR).status(userId, connectorEntity.getConnectorId());
329 + updateConnectorStatus(connectorEntity, status.runtimeStatus(), status.authStatus());
330 + if (!status.authenticated()) {
331 + entity.setStatus(BrokerConnectionState.AUTHENTICATION_REQUIRED);
332 + entity.setProviderStatus(BrokerProviderStatus.AUTHENTICATION_REQUIRED.name());
333 + entity.setLastErrorCode(status.code());
334 + }
335 + } else if (entity.getBrokerType() != BrokerType.MOCK) {
336 + providerFor(entity.getBrokerType()).fetchAccounts(userId, connectionId);
337 + }
338 + entity.setUpdatedAt(Instant.now());
339 + return BrokerConnectionMapper.toDomain(entity);
340 + }
341 +
342 + @Transactional
343 + public BrokerConnection sync(UUID userId, UUID connectionId) {
344 Instant startedAt = Instant.now();
89 - BrokerConnectionEntity entity = repository.findById(connectionId)
345 + BrokerConnectionEntity entity = repository.findForUpdateByConnectionIdAndUserId(connectionId, userId)
346 .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
347 Instant now = Instant.now();
348 entity.setStatus(BrokerConnectionState.SYNCING);
@@ -105,7 +361,54 @@ public class BrokerConnectionService {
361 auditor.record(entity.getBrokerType(), "sync", startedAt, false, "RATE_LIMITED");
362 return BrokerConnectionMapper.toDomain(entity);
363 }
108 - if (provider.connectionStatus().state() == BrokerConnectionState.DISCONNECTED && entity.getBrokerType() != BrokerType.MOCK) {
364 + if (entity.getBrokerType() == BrokerType.IBKR) {
365 + try {
366 + BrokerConnectorInstanceEntity connectorEntity = requireConnector(userId, entity);
367 + BrokerConnector connector = connectorFor(BrokerType.IBKR);
368 + var connectorStatus = connector.status(userId, connectorEntity.getConnectorId());
369 + updateConnectorStatus(connectorEntity, connectorStatus.runtimeStatus(), connectorStatus.authStatus());
370 + if (!connectorStatus.authenticated()) {
371 + entity.setStatus(BrokerConnectionState.AUTHENTICATION_REQUIRED);
372 + entity.setProviderStatus(BrokerProviderStatus.AUTHENTICATION_REQUIRED.name());
373 + entity.setLastErrorCode(connectorStatus.code());
374 + throw BrokerProviderException.sessionExpired();
375 + }
376 + List<BrokerAccount> accounts = connector.fetchAccounts(userId, connectorEntity.getConnectorId());
377 + BrokerAccount account = accounts.stream()
378 + .filter(candidate -> candidate.externalAccountReference().equals(entity.getExternalAccountReference()))
379 + .findFirst()
380 + .orElse(accounts.get(0));
381 + connector.fetchPositions(userId, connectorEntity.getConnectorId(), account);
382 + connector.fetchCashBalances(userId, connectorEntity.getConnectorId(), account);
383 + entity.setStatus(BrokerConnectionState.CONNECTED);
384 + entity.setExternalAccountReference(account.externalAccountReference());
385 + entity.setDisplayName(account.displayName());
386 + entity.setAccountCurrency(account.baseCurrency());
387 + entity.setProviderStatus(BrokerProviderStatus.CONNECTED.name());
388 + entity.setDataFreshness("REAL_BROKER");
389 + entity.setSessionReference("client-portal-gateway");
390 + entity.setCapabilities(capabilities(provider));
391 + entity.setLastSuccessfulSyncAt(now);
392 + entity.setLastErrorCode(null);
393 + auditor.record(entity.getBrokerType(), "sync", startedAt, true, "OK");
394 + return BrokerConnectionMapper.toDomain(entity);
395 + } catch (BrokerProviderException exception) {
396 + if ("BROKER_SESSION_EXPIRED".equals(exception.code())) {
397 + entity.setStatus(BrokerConnectionState.AUTHENTICATION_REQUIRED);
398 + entity.setProviderStatus(BrokerProviderStatus.AUTHENTICATION_REQUIRED.name());
399 + } else {
400 + entity.setStatus(BrokerConnectionState.ERROR);
401 + entity.setProviderStatus(BrokerProviderStatus.ERROR.name());
402 + }
403 + entity.setLastErrorCode(exception.code());
404 + auditor.record(entity.getBrokerType(), "sync", startedAt, false, exception.code());
405 + if ("BROKER_SESSION_EXPIRED".equals(exception.code())) {
406 + return BrokerConnectionMapper.toDomain(entity);
407 + }
408 + throw exception;
409 + }
410 + }
411 + if (provider.connectionStatus(userId, connectionId).state() == BrokerConnectionState.DISCONNECTED && entity.getBrokerType() != BrokerType.MOCK) {
412 entity.setStatus(BrokerConnectionState.ERROR);
413 entity.setLastErrorCode(provider.connectionStatus().code());
414 circuitBreaker.recordFailure(entity.getBrokerType());
@@ -120,11 +423,82 @@ public class BrokerConnectionService {
423 return BrokerConnectionMapper.toDomain(entity);
424 }
425
426 + @Transactional(readOnly = true)
427 + public BrokerSnapshotResponse snapshot(UUID userId, UUID connectionId) {
428 + BrokerConnectionEntity entity = repository.findByConnectionIdAndUserId(connectionId, userId)
429 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
430 + BrokerProvider provider = providerFor(entity.getBrokerType());
431 + List<BrokerAccount> accounts;
432 + List<BrokerPosition> positions;
433 + List<BrokerCashBalance> cashBalances;
434 + if (entity.getBrokerType() == BrokerType.IBKR) {
435 + BrokerConnectorInstanceEntity connectorEntity = requireConnector(userId, entity);
436 + BrokerConnector connector = connectorFor(BrokerType.IBKR);
437 + accounts = connector.fetchAccounts(userId, connectorEntity.getConnectorId());
438 + if (entity.getExternalAccountReference() != null) {
439 + accounts = accounts.stream()
440 + .filter(account -> entity.getExternalAccountReference().equals(account.externalAccountReference()))
441 + .toList();
442 + }
443 + if (accounts.isEmpty()) {
444 + throw BrokerProviderException.accountNotFound();
445 + }
446 + positions = accounts.stream()
447 + .flatMap(account -> connector.fetchPositions(userId, connectorEntity.getConnectorId(), account).stream())
448 + .toList();
449 + cashBalances = accounts.stream()
450 + .flatMap(account -> connector.fetchCashBalances(userId, connectorEntity.getConnectorId(), account).stream())
451 + .toList();
452 + } else {
453 + accounts = provider.fetchAccounts(userId, connectionId);
454 + accounts = accounts.stream()
455 + .toList();
456 + if (accounts.isEmpty()) {
457 + throw BrokerProviderException.accountNotFound();
458 + }
459 + positions = accounts.stream().flatMap(account -> provider.fetchPositions(userId, connectionId, account).stream()).toList();
460 + cashBalances = accounts.stream().flatMap(account -> provider.fetchCashBalances(userId, connectionId, account).stream()).toList();
461 + }
462 + String freshness = entity.getBrokerType() == BrokerType.MOCK ? "DEMO" : "REAL_BROKER";
463 + return new BrokerSnapshotResponse(
464 + BrokerConnectionResponse.from(BrokerConnectionMapper.toDomain(entity), provider),
465 + accounts.stream().map(BrokerSnapshotResponse.AccountResponse::from).toList(),
466 + positions.stream().map(position -> BrokerSnapshotResponse.PositionResponse.from(position, freshness)).toList(),
467 + cashBalances.stream().map(BrokerSnapshotResponse.CashResponse::from).toList()
468 + );
469 + }
470 +
471 + @Transactional(readOnly = true)
472 + public List<BrokerSnapshotResponse.AccountResponse> inspectAccounts(UUID userId, UUID connectionId) {
473 + BrokerProvider provider = requireIciciReadProvider(userId, connectionId);
474 + return provider.fetchAccounts(userId, connectionId).stream()
475 + .map(BrokerSnapshotResponse.AccountResponse::from)
476 + .toList();
477 + }
478 +
479 + @Transactional(readOnly = true)
480 + public List<BrokerSnapshotResponse.PositionResponse> inspectDematHoldings(UUID userId, UUID connectionId) {
481 + BrokerProvider provider = requireIciciReadProvider(userId, connectionId);
482 + return provider.fetchAccounts(userId, connectionId).stream()
483 + .flatMap(account -> provider.fetchPositions(userId, connectionId, account).stream())
484 + .map(position -> BrokerSnapshotResponse.PositionResponse.from(position, "REAL_BROKER"))
485 + .toList();
486 + }
487 +
488 + @Transactional(readOnly = true)
489 + public List<BrokerSnapshotResponse.CashResponse> inspectFunds(UUID userId, UUID connectionId) {
490 + BrokerProvider provider = requireIciciReadProvider(userId, connectionId);
491 + return provider.fetchAccounts(userId, connectionId).stream()
492 + .flatMap(account -> provider.fetchCashBalances(userId, connectionId, account).stream())
493 + .map(BrokerSnapshotResponse.CashResponse::from)
494 + .toList();
495 + }
496 +
497 @Transactional
124 - public void disconnect(UUID connectionId) {
125 - BrokerConnectionEntity entity = repository.findById(connectionId)
498 + public void disconnect(UUID userId, UUID connectionId) {
499 + BrokerConnectionEntity entity = repository.findByConnectionIdAndUserId(connectionId, userId)
500 .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
127 - providerFor(entity.getBrokerType()).disconnect(connectionId);
501 + providerFor(entity.getBrokerType()).disconnect(userId, connectionId);
502 entity.setStatus(BrokerConnectionState.DISCONNECTED);
503 entity.setUpdatedAt(Instant.now());
504 }
@@ -133,8 +507,137 @@ public class BrokerConnectionService {
507 return providers;
508 }
509
510 + public String consumerAuthMode(BrokerType brokerType) {
511 + return consumerAuthMode(brokerType, false);
512 + }
513 +
514 + public String consumerAuthMode(BrokerType brokerType, boolean advancedIndividualMode) {
515 + if (brokerType == BrokerType.IBKR) return "BROKER_REDIRECT";
516 + if (brokerType == BrokerType.ICICI_DIRECT || brokerType == BrokerType.HDFC_SECURITIES) {
517 + if (partnerAuthProvider(brokerType).isPresent()) return "PARTNER_OAUTH";
518 + return advancedIndividualMode ? "INDIVIDUAL_API_CREDENTIALS" : "PARTNER_UNAVAILABLE";
519 + }
520 + return "NONE";
521 + }
522 +
523 private BrokerProvider providerFor(BrokerType brokerType) {
524 return providers.stream().filter(provider -> provider.supportedBroker() == brokerType).findFirst()
525 .orElseThrow(() -> new IllegalStateException("No provider for " + brokerType));
526 }
527 +
528 + private ICICIDirectBrokerProvider iciciProvider() {
529 + return (ICICIDirectBrokerProvider) providerFor(BrokerType.ICICI_DIRECT);
530 + }
531 +
532 + private HDFCSecuritiesBrokerProvider hdfcProvider() {
533 + return (HDFCSecuritiesBrokerProvider) providerFor(BrokerType.HDFC_SECURITIES);
534 + }
535 +
536 + private Optional<PartnerAuthProvider> partnerAuthProvider(BrokerType brokerType) {
537 + return partnerAuthProviders.stream().filter(provider -> provider.brokerType() == brokerType).findFirst();
538 + }
539 +
540 + private BrokerConnectionEntity requireProviderConnection(UUID userId, UUID connectionId, BrokerType type) {
541 + BrokerConnectionEntity entity = repository.findByConnectionIdAndUserId(connectionId, userId)
542 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
543 + if (entity.getBrokerType() != type) throw new IllegalArgumentException("Connection provider does not match");
544 + return entity;
545 + }
546 +
547 + private BrokerProvider requireIciciReadProvider(UUID userId, UUID connectionId) {
548 + requireIciciConnection(userId, connectionId);
549 + return providerFor(BrokerType.ICICI_DIRECT);
550 + }
551 +
552 + private BrokerConnectionEntity requireIciciConnection(UUID userId, UUID connectionId) {
553 + BrokerConnectionEntity entity = repository.findByConnectionIdAndUserId(connectionId, userId)
554 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connectionId));
555 + if (entity.getBrokerType() != BrokerType.ICICI_DIRECT) {
556 + throw new IllegalArgumentException("Connection is not an ICICI Direct connection");
557 + }
558 + return entity;
559 + }
560 +
561 + private BrokerConnector connectorFor(BrokerType brokerType) {
562 + return connectors.stream().filter(connector -> connector.brokerType() == brokerType).findFirst()
563 + .orElseThrow(() -> new IllegalStateException("No connector for " + brokerType));
564 + }
565 +
566 + private BrokerConnectorInstanceEntity createConnectorInstance(UUID userId, BrokerConnector connector) {
567 + Instant now = Instant.now();
568 + ConnectorRuntimeMode runtimeMode = connector.runtimeMode();
569 + UUID connectorId = UUID.randomUUID();
570 + BrokerConnectorInstanceEntity entity = new BrokerConnectorInstanceEntity(connectorId, userId,
571 + connector.brokerType(), runtimeMode, BrokerConnectorState.STARTING,
572 + BrokerConnectorState.AUTHENTICATION_REQUIRED, null,
573 + ibkrProperties.connectorIdleTimeoutSeconds(), ibkrProperties.connectorSessionTimeoutSeconds(), now, now);
574 + BrokerConnectorInstanceEntity saved = connectorRepository.save(entity);
575 + var status = connector.start(userId, connectorId);
576 + updateConnectorStatus(saved, status.runtimeStatus(), status.authStatus());
577 + saved.setLoginUrl(status.loginUrl());
578 + return saved;
579 + }
580 +
581 + private BrokerConnectorInstanceEntity requireConnector(UUID userId, BrokerConnectionEntity connection) {
582 + if (connection.getConnectorId() == null) {
583 + throw BrokerProviderException.authenticationRequired();
584 + }
585 + return connectorRepository.findByConnectorIdAndUserId(connection.getConnectorId(), userId)
586 + .orElseThrow(() -> new BrokerConnectionNotFoundException(connection.getConnectionId()));
587 + }
588 +
589 + private static void updateConnectorStatus(BrokerConnectorInstanceEntity entity,
590 + BrokerConnectorState runtimeStatus,
591 + BrokerConnectorState authStatus) {
592 + Instant now = Instant.now();
593 + entity.setRuntimeStatus(runtimeStatus);
594 + entity.setAuthStatus(authStatus);
595 + entity.setLastHeartbeatAt(now);
596 + if (authStatus == BrokerConnectorState.CONNECTED) {
597 + entity.setLastAuthenticatedAt(now);
598 + }
599 + entity.setUpdatedAt(now);
600 + }
601 +
602 + private static void reconcileIbkrConnectionStatus(BrokerConnectionEntity entity,
603 + com.aiinvestment.broker.connector.BrokerConnectorStatus connectorStatus) {
604 + if (connectorStatus.authenticated()) {
605 + entity.setStatus(BrokerConnectionState.CONNECTED);
606 + entity.setProviderStatus(BrokerProviderStatus.CONNECTED.name());
607 + entity.setLastErrorCode(null);
608 + } else if (connectorStatus.authStatus() == BrokerConnectorState.AUTHENTICATION_REQUIRED
609 + || connectorStatus.authStatus() == BrokerConnectorState.SESSION_EXPIRED
610 + || connectorStatus.runtimeStatus() == BrokerConnectorState.AUTHENTICATION_REQUIRED
611 + || connectorStatus.runtimeStatus() == BrokerConnectorState.SESSION_EXPIRED) {
612 + entity.setStatus(BrokerConnectionState.AUTHENTICATION_REQUIRED);
613 + entity.setProviderStatus(BrokerProviderStatus.AUTHENTICATION_REQUIRED.name());
614 + entity.setLastErrorCode(connectorStatus.code());
615 + } else {
616 + entity.setStatus(BrokerConnectionState.ERROR);
617 + entity.setProviderStatus(BrokerProviderStatus.ERROR.name());
618 + entity.setLastErrorCode(connectorStatus.code());
619 + }
620 + entity.setUpdatedAt(Instant.now());
621 + }
622 +
623 + private static void reconcileProviderStatus(BrokerConnectionEntity entity, BrokerConnectionStatus status) {
624 + entity.setStatus(status.state());
625 + entity.setProviderStatus(status.providerStatus().name());
626 + entity.setLastErrorCode(status.providerStatus() == BrokerProviderStatus.CONNECTED ? null : status.code());
627 + entity.setUpdatedAt(Instant.now());
628 + }
629 +
630 + private static String capabilities(BrokerProvider provider) {
631 + return provider.connectionCapabilities().capabilities().stream()
632 + .map(Enum::name)
633 + .sorted()
634 + .collect(Collectors.joining(","));
635 + }
636 +
637 + private static String capabilities(BrokerProvider provider, UUID userId, UUID connectionId) {
638 + return provider.connectionCapabilities(userId, connectionId).capabilities().stream()
639 + .map(Enum::name)
640 + .sorted()
641 + .collect(Collectors.joining(","));
642 + }
643 }