feat: button states, full prompt extraction, portfolio viewer, param inputs

1. Run buttons: only the triggered button shows spinner, others disabled
2. Backend: enhanced prompt extraction with multiple fallback paths
   (data.messages, data.input.messages, data.input, data.kwargs.messages)
   and raw dump fallback; improved response extraction for edge cases
3. Portfolio viewer: new PortfolioViewer component with holdings table,
   trade history, and summary tabs; portfolio dropdown with auto-load;
   Wallet sidebar icon now navigates to portfolio page
4. Parameter inputs: collapsible panel with date/ticker/portfolio_id;
   validation prevents running without required fields per run type

Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>
Agent-Logs-Url: https://github.com/aguzererler/TradingAgents/sessions/ffa268c8-e97c-4335-9bce-19bba583bea9
This commit is contained in:
copilot-swe-agent[bot] 2026-03-23 08:46:34 +00:00
parent b08ce7199e
commit 6999da0827
3 changed files with 595 additions and 129 deletions

View File

@ -233,19 +233,35 @@ class LangGraphEngine:
Returns the concatenated content of every message so the user can Returns the concatenated content of every message so the user can
inspect the full prompt that was sent to the LLM. inspect the full prompt that was sent to the LLM.
Handles several structures observed across LangChain / LangGraph versions:
- flat list of message objects ``[SystemMessage, HumanMessage, ...]``
- list-of-lists (batched) ``[[SystemMessage, HumanMessage, ...]]``
- list of plain dicts ``[{"role": "system", "content": "..."}]``
- tuple wrapper ``([SystemMessage, ...],)``
""" """
if not isinstance(messages, list) or not messages: if not messages:
return "" return ""
# Unwrap single-element tuple / list-of-lists
items: list = messages if isinstance(messages, list) else list(messages)
if items and isinstance(items[0], (list, tuple)):
items = list(items[0])
parts: list[str] = [] parts: list[str] = []
items = messages
# Handle list-of-lists
if isinstance(items[0], list):
items = items[0]
for msg in items: for msg in items:
# LangChain message objects have .content and .type
content = getattr(msg, "content", None) content = getattr(msg, "content", None)
role = getattr(msg, "type", "unknown") role = getattr(msg, "type", None)
# Plain-dict messages (e.g. {"role": "user", "content": "..."})
if content is None and isinstance(msg, dict):
content = msg.get("content", "")
role = msg.get("role") or msg.get("type") or "unknown"
if role is None:
role = "unknown"
text = str(content) if content is not None else str(msg) text = str(content) if content is not None else str(msg)
parts.append(f"[{role}] {text}") parts.append(f"[{role}] {text}")
return "\n\n".join(parts) return "\n\n".join(parts)
def _extract_model(self, event: Dict[str, Any]) -> str: def _extract_model(self, event: Dict[str, Any]) -> str:
@ -288,13 +304,35 @@ class LangGraphEngine:
if kind == "on_chat_model_start": if kind == "on_chat_model_start":
starts[node_name] = time.monotonic() starts[node_name] = time.monotonic()
# Extract the full prompt being sent to the LLM data = event.get("data") or {}
# Extract the full prompt being sent to the LLM.
# Try multiple paths observed in different LangChain versions:
# 1. data.messages (most common)
# 2. data.input.messages (newer LangGraph)
# 3. data.input (if it's a list of messages itself)
# 4. data.kwargs.messages (some providers)
full_prompt = "" full_prompt = ""
prompt_snippet = "" for source in (
messages = (event.get("data") or {}).get("messages") data.get("messages"),
if messages: (data.get("input") or {}).get("messages") if isinstance(data.get("input"), dict) else None,
full_prompt = self._extract_all_messages_content(messages) data.get("input") if isinstance(data.get("input"), (list, tuple)) else None,
prompt_snippet = self._truncate(full_prompt.replace("\n", " ")) (data.get("kwargs") or {}).get("messages"),
):
if source:
full_prompt = self._extract_all_messages_content(source)
if full_prompt:
break
# If all structured extractions failed, dump a raw preview
if not full_prompt:
raw_dump = str(data)[:_MAX_FULL_LEN]
if raw_dump and raw_dump != "{}":
full_prompt = f"[raw event data] {raw_dump}"
prompt_snippet = self._truncate(
full_prompt.replace("\n", " "), _MAX_CONTENT_LEN
) if full_prompt else ""
# Remember the full prompt so we can attach it to the result event # Remember the full prompt so we can attach it to the result event
prompts[node_name] = full_prompt prompts[node_name] = full_prompt
@ -377,7 +415,16 @@ class LangGraphEngine:
usage = output.usage_metadata usage = output.usage_metadata
if hasattr(output, "response_metadata") and output.response_metadata: if hasattr(output, "response_metadata") and output.response_metadata:
model = output.response_metadata.get("model_name") or output.response_metadata.get("model", model) model = output.response_metadata.get("model_name") or output.response_metadata.get("model", model)
# Extract the response text handle both message objects and plain dicts
raw = self._extract_content(output) raw = self._extract_content(output)
# If .content was empty or the repr of the whole object, try harder
if not raw or raw.startswith("<") or raw == str(output):
# Some providers wrap in .text or .message
raw = (
getattr(output, "text", "")
or (output.get("content", "") if isinstance(output, dict) else "")
)
if raw: if raw:
full_response = raw[:_MAX_FULL_LEN] full_response = raw[:_MAX_FULL_LEN]
response_snippet = self._truncate(raw) response_snippet = self._truncate(raw)

View File

@ -7,6 +7,7 @@ import {
Text, Text,
IconButton, IconButton,
Button, Button,
Input,
useDisclosure, useDisclosure,
Drawer, Drawer,
DrawerOverlay, DrawerOverlay,
@ -29,15 +30,43 @@ import {
TabPanels, TabPanels,
Tab, Tab,
TabPanel, TabPanel,
Tooltip,
Collapse,
useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { LayoutDashboard, Wallet, Settings, Play, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot } from 'lucide-react'; import { LayoutDashboard, Wallet, Settings, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot, ChevronDown, ChevronUp } from 'lucide-react';
import { MetricHeader } from './components/MetricHeader'; import { MetricHeader } from './components/MetricHeader';
import { AgentGraph } from './components/AgentGraph'; import { AgentGraph } from './components/AgentGraph';
import { PortfolioViewer } from './components/PortfolioViewer';
import { useAgentStream, AgentEvent } from './hooks/useAgentStream'; import { useAgentStream, AgentEvent } from './hooks/useAgentStream';
import axios from 'axios'; import axios from 'axios';
const API_BASE = 'http://127.0.0.1:8088/api'; const API_BASE = 'http://127.0.0.1:8088/api';
// ─── Run type definitions with required parameters ────────────────────
type RunType = 'scan' | 'pipeline' | 'portfolio' | 'auto';
interface RunParams {
date: string;
ticker: string;
portfolio_id: string;
}
const RUN_TYPE_LABELS: Record<RunType, string> = {
scan: 'Scan',
pipeline: 'Pipeline',
portfolio: 'Portfolio',
auto: 'Auto',
};
/** Which params each run type needs. */
const REQUIRED_PARAMS: Record<RunType, (keyof RunParams)[]> = {
scan: ['date'],
pipeline: ['ticker', 'date'],
portfolio: ['date', 'portfolio_id'],
auto: ['date', 'ticker'],
};
/** Return the colour token for a given event type. */ /** Return the colour token for a given event type. */
const eventColor = (type: AgentEvent['type']): string => { const eventColor = (type: AgentEvent['type']): string => {
switch (type) { switch (type) {
@ -256,12 +285,17 @@ const NodeEventsDetail: React.FC<{ nodeId: string; events: AgentEvent[]; onOpenM
); );
}; };
// ─── Sidebar page type ────────────────────────────────────────────────
type Page = 'dashboard' | 'portfolio';
export const Dashboard: React.FC = () => { export const Dashboard: React.FC = () => {
const [activePage, setActivePage] = useState<Page>('dashboard');
const [activeRunId, setActiveRunId] = useState<string | null>(null); const [activeRunId, setActiveRunId] = useState<string | null>(null);
const [activeRunType, setActiveRunType] = useState<RunType | null>(null);
const [isTriggering, setIsTriggering] = useState(false); const [isTriggering, setIsTriggering] = useState(false);
const [portfolioId, setPortfolioId] = useState<string>("main_portfolio");
const { events, status, clearEvents } = useAgentStream(activeRunId); const { events, status, clearEvents } = useAgentStream(activeRunId);
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// Event detail modal state // Event detail modal state
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure(); const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
@ -272,6 +306,14 @@ export const Dashboard: React.FC = () => {
const [selectedEvent, setSelectedEvent] = useState<AgentEvent | null>(null); const [selectedEvent, setSelectedEvent] = useState<AgentEvent | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
// Parameter inputs
const [showParams, setShowParams] = useState(false);
const [params, setParams] = useState<RunParams>({
date: new Date().toISOString().split('T')[0],
ticker: 'AAPL',
portfolio_id: 'main_portfolio',
});
// Auto-scroll the terminal to the bottom as new events arrive // Auto-scroll the terminal to the bottom as new events arrive
const terminalEndRef = useRef<HTMLDivElement>(null); const terminalEndRef = useRef<HTMLDivElement>(null);
@ -279,21 +321,47 @@ export const Dashboard: React.FC = () => {
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' }); terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [events.length]); }, [events.length]);
// Clear activeRunType when run completes
useEffect(() => {
if (status === 'completed' || status === 'error') {
setActiveRunType(null);
}
}, [status]);
const isRunning = isTriggering || status === 'streaming' || status === 'connecting'; const isRunning = isTriggering || status === 'streaming' || status === 'connecting';
const startRun = async (type: string) => { const startRun = async (type: RunType) => {
if (isRunning) return; if (isRunning) return;
// Validate required params
const required = REQUIRED_PARAMS[type];
const missing = required.filter((k) => !params[k]?.trim());
if (missing.length > 0) {
toast({
title: `Missing required fields for ${RUN_TYPE_LABELS[type]}`,
description: `Please fill in: ${missing.join(', ')}`,
status: 'warning',
duration: 3000,
isClosable: true,
position: 'top',
});
setShowParams(true);
return;
}
setIsTriggering(true); setIsTriggering(true);
setActiveRunType(type);
try { try {
clearEvents(); clearEvents();
const res = await axios.post(`${API_BASE}/run/${type}`, { const res = await axios.post(`${API_BASE}/run/${type}`, {
portfolio_id: portfolioId, portfolio_id: params.portfolio_id,
date: new Date().toISOString().split('T')[0] date: params.date,
ticker: params.ticker,
}); });
setActiveRunId(res.data.run_id); setActiveRunId(res.data.run_id);
} catch (err) { } catch (err) {
console.error("Failed to start run:", err); console.error("Failed to start run:", err);
setActiveRunType(null);
} finally { } finally {
setIsTriggering(false); setIsTriggering(false);
} }
@ -331,125 +399,196 @@ export const Dashboard: React.FC = () => {
{/* Sidebar */} {/* Sidebar */}
<VStack w="64px" bg="slate.900" borderRight="1px solid" borderColor="whiteAlpha.100" py={4} spacing={6}> <VStack w="64px" bg="slate.900" borderRight="1px solid" borderColor="whiteAlpha.100" py={4} spacing={6}>
<Box mb={4}><Text fontWeight="black" color="cyan.400" fontSize="xl">A</Text></Box> <Box mb={4}><Text fontWeight="black" color="cyan.400" fontSize="xl">A</Text></Box>
<IconButton aria-label="Dashboard" icon={<LayoutDashboard size={20} />} variant="ghost" color="cyan.400" _hover={{ bg: "whiteAlpha.100" }} /> <Tooltip label="Dashboard" placement="right">
<IconButton aria-label="Portfolio" icon={<Wallet size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} /> <IconButton
aria-label="Dashboard"
icon={<LayoutDashboard size={20} />}
variant="ghost"
color={activePage === 'dashboard' ? 'cyan.400' : 'whiteAlpha.600'}
bg={activePage === 'dashboard' ? 'whiteAlpha.100' : 'transparent'}
_hover={{ bg: "whiteAlpha.100" }}
onClick={() => setActivePage('dashboard')}
/>
</Tooltip>
<Tooltip label="Portfolio" placement="right">
<IconButton
aria-label="Portfolio"
icon={<Wallet size={20} />}
variant="ghost"
color={activePage === 'portfolio' ? 'cyan.400' : 'whiteAlpha.600'}
bg={activePage === 'portfolio' ? 'whiteAlpha.100' : 'transparent'}
_hover={{ bg: "whiteAlpha.100" }}
onClick={() => setActivePage('portfolio')}
/>
</Tooltip>
<IconButton aria-label="Settings" icon={<Settings size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} /> <IconButton aria-label="Settings" icon={<Settings size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
</VStack> </VStack>
{/* Main Content */} {/* ─── Portfolio Page ────────────────────────────────────────── */}
<Flex flex="1" direction="column"> {activePage === 'portfolio' && (
{/* Top Metric Header */} <Box flex="1">
<MetricHeader portfolioId={portfolioId} /> <PortfolioViewer defaultPortfolioId={params.portfolio_id} />
</Box>
)}
{/* Dashboard Body */} {/* ─── Dashboard Page ────────────────────────────────────────── */}
<Flex flex="1" overflow="hidden"> {activePage === 'dashboard' && (
{/* Left Side: Graph Area */} <Flex flex="1" direction="column">
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100"> {/* Top Metric Header */}
<AgentGraph events={events} onNodeClick={openNodeDetail} /> <MetricHeader portfolioId={params.portfolio_id} />
{/* Floating Control Panel */}
<HStack position="absolute" top={4} left={4} bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={2} flexWrap="wrap">
<Button
size="sm"
leftIcon={<Search size={14} />}
colorScheme="cyan"
variant="solid"
onClick={() => startRun('scan')}
isLoading={isRunning}
loadingText="Running…"
>
Scan
</Button>
<Button
size="sm"
leftIcon={<BarChart3 size={14} />}
colorScheme="blue"
variant="solid"
onClick={() => startRun('pipeline')}
isLoading={isRunning}
loadingText="Running…"
>
Pipeline
</Button>
<Button
size="sm"
leftIcon={<Wallet size={14} />}
colorScheme="purple"
variant="solid"
onClick={() => startRun('portfolio')}
isLoading={isRunning}
loadingText="Running…"
>
Portfolio
</Button>
<Button
size="sm"
leftIcon={<Bot size={14} />}
colorScheme="green"
variant="solid"
onClick={() => startRun('auto')}
isLoading={isRunning}
loadingText="Running…"
>
Auto
</Button>
<Divider orientation="vertical" h="20px" />
<Tag size="sm" colorScheme={status === 'streaming' ? 'green' : status === 'completed' ? 'blue' : 'gray'}>
{status.toUpperCase()}
</Tag>
</HStack>
</Box>
{/* Right Side: Live Terminal */} {/* Dashboard Body */}
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}> <Flex flex="1" overflow="hidden">
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100"> {/* Left Side: Graph Area */}
<TerminalIcon size={16} color="#4fd1c5" /> <Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text> <AgentGraph events={events} onNodeClick={openNodeDetail} />
<Text fontSize="2xs" color="whiteAlpha.400" ml="auto">{events.length} events</Text>
</Flex> {/* Floating Control Panel */}
<VStack position="absolute" top={4} left={4} spacing={2} align="stretch">
<Box flex="1" overflowY="auto" p={4} sx={{ {/* Run buttons row */}
'&::-webkit-scrollbar': { width: '4px' }, <HStack bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={2}>
'&::-webkit-scrollbar-track': { background: 'transparent' }, {(['scan', 'pipeline', 'portfolio', 'auto'] as RunType[]).map((type) => {
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' } const isThisRunning = isRunning && activeRunType === type;
}}> const isOtherRunning = isRunning && activeRunType !== type;
{events.map((evt) => ( const icons: Record<RunType, React.ReactElement> = {
<Box scan: <Search size={14} />,
key={evt.id} pipeline: <BarChart3 size={14} />,
mb={2} portfolio: <Wallet size={14} />,
fontSize="xs" auto: <Bot size={14} />,
fontFamily="mono" };
px={2} const colors: Record<RunType, string> = {
py={1} scan: 'cyan',
borderRadius="md" pipeline: 'blue',
cursor="pointer" portfolio: 'purple',
_hover={{ bg: 'whiteAlpha.100' }} auto: 'green',
onClick={() => openEventDetail(evt)} };
transition="background 0.15s" return (
> <Button
<Flex gap={2} align="center"> key={type}
<Text color="whiteAlpha.400" minW="52px" flexShrink={0}>[{evt.timestamp}]</Text> size="sm"
<Text flexShrink={0}>{eventLabel(evt.type)}</Text> leftIcon={icons[type]}
<Text color={eventColor(evt.type)} fontWeight="bold" flexShrink={0}> colorScheme={colors[type]}
{evt.agent} variant="solid"
</Text> onClick={() => startRun(type)}
<ChevronRight size={10} style={{ flexShrink: 0, opacity: 0.4 }} /> isLoading={isThisRunning}
<Text color="whiteAlpha.700" isTruncated>{eventSummary(evt)}</Text> loadingText="Running…"
<Eye size={12} style={{ flexShrink: 0, opacity: 0.3, marginLeft: 'auto' }} /> isDisabled={isOtherRunning}
</Flex> >
</Box> {RUN_TYPE_LABELS[type]}
))} </Button>
{events.length === 0 && ( );
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}> })}
<TerminalIcon size={48} /> <Divider orientation="vertical" h="20px" />
<Text fontSize="sm">Awaiting agent activation...</Text> <Tag size="sm" colorScheme={status === 'streaming' ? 'green' : status === 'completed' ? 'blue' : status === 'error' ? 'red' : 'gray'}>
</Flex> {status.toUpperCase()}
)} </Tag>
<div ref={terminalEndRef} /> <IconButton
aria-label="Toggle params"
icon={showParams ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
size="xs"
variant="ghost"
color="whiteAlpha.600"
onClick={() => setShowParams(!showParams)}
/>
</HStack>
{/* Collapsible parameter inputs */}
<Collapse in={showParams} animateOpacity>
<Box bg="blackAlpha.800" p={3} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200">
<VStack spacing={2} align="stretch">
<HStack>
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Date:</Text>
<Input
size="xs"
type="date"
bg="whiteAlpha.100"
borderColor="whiteAlpha.200"
value={params.date}
onChange={(e) => setParams((p) => ({ ...p, date: e.target.value }))}
/>
</HStack>
<HStack>
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Ticker:</Text>
<Input
size="xs"
placeholder="AAPL"
bg="whiteAlpha.100"
borderColor="whiteAlpha.200"
value={params.ticker}
onChange={(e) => setParams((p) => ({ ...p, ticker: e.target.value.toUpperCase() }))}
/>
</HStack>
<HStack>
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Portfolio:</Text>
<Input
size="xs"
placeholder="main_portfolio"
bg="whiteAlpha.100"
borderColor="whiteAlpha.200"
value={params.portfolio_id}
onChange={(e) => setParams((p) => ({ ...p, portfolio_id: e.target.value }))}
/>
</HStack>
<Text fontSize="2xs" color="whiteAlpha.400">
Required: Scan date · Pipeline ticker, date · Portfolio date, portfolio · Auto date, ticker
</Text>
</VStack>
</Box>
</Collapse>
</VStack>
</Box> </Box>
</VStack>
{/* Right Side: Live Terminal */}
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}>
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100">
<TerminalIcon size={16} color="#4fd1c5" />
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text>
<Text fontSize="2xs" color="whiteAlpha.400" ml="auto">{events.length} events</Text>
</Flex>
<Box flex="1" overflowY="auto" p={4} sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' }
}}>
{events.map((evt) => (
<Box
key={evt.id}
mb={2}
fontSize="xs"
fontFamily="mono"
px={2}
py={1}
borderRadius="md"
cursor="pointer"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => openEventDetail(evt)}
transition="background 0.15s"
>
<Flex gap={2} align="center">
<Text color="whiteAlpha.400" minW="52px" flexShrink={0}>[{evt.timestamp}]</Text>
<Text flexShrink={0}>{eventLabel(evt.type)}</Text>
<Text color={eventColor(evt.type)} fontWeight="bold" flexShrink={0}>
{evt.agent}
</Text>
<ChevronRight size={10} style={{ flexShrink: 0, opacity: 0.4 }} />
<Text color="whiteAlpha.700" isTruncated>{eventSummary(evt)}</Text>
<Eye size={12} style={{ flexShrink: 0, opacity: 0.3, marginLeft: 'auto' }} />
</Flex>
</Box>
))}
{events.length === 0 && (
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
<TerminalIcon size={48} />
<Text fontSize="sm">Awaiting agent activation...</Text>
</Flex>
)}
<div ref={terminalEndRef} />
</Box>
</VStack>
</Flex>
</Flex> </Flex>
</Flex> )}
{/* Unified Inspector Drawer (single event or all node events) */} {/* Unified Inspector Drawer (single event or all node events) */}
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md"> <Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">

View File

@ -0,0 +1,280 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Flex,
VStack,
HStack,
Text,
Badge,
Code,
Spinner,
Select,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Icon,
} from '@chakra-ui/react';
import { Wallet, ArrowUpRight, ArrowDownRight, RefreshCw } from 'lucide-react';
import axios from 'axios';
const API_BASE = 'http://127.0.0.1:8088/api';
interface Holding {
ticker: string;
quantity: number;
avg_cost: number;
current_price?: number;
market_value?: number;
unrealized_pnl?: number;
sector?: string;
[key: string]: unknown;
}
interface Trade {
id?: string;
ticker: string;
action: string;
quantity: number;
price: number;
executed_at?: string;
rationale?: string;
[key: string]: unknown;
}
interface PortfolioInfo {
id: string;
name?: string;
cash_balance?: number;
[key: string]: unknown;
}
interface PortfolioState {
portfolio: PortfolioInfo;
snapshot: Record<string, unknown> | null;
holdings: Holding[];
recent_trades: Trade[];
}
interface PortfolioViewerProps {
defaultPortfolioId?: string;
}
export const PortfolioViewer: React.FC<PortfolioViewerProps> = ({ defaultPortfolioId = 'main_portfolio' }) => {
const [portfolios, setPortfolios] = useState<PortfolioInfo[]>([]);
const [selectedId, setSelectedId] = useState<string>(defaultPortfolioId);
const [state, setState] = useState<PortfolioState | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch portfolio list
useEffect(() => {
const fetchList = async () => {
try {
const res = await axios.get(`${API_BASE}/portfolios/`);
const list = res.data as PortfolioInfo[];
setPortfolios(list);
if (list.length > 0 && !list.find((p) => p.id === selectedId)) {
setSelectedId(list[0].id);
}
} catch {
// Might fail if no DB — use fallback
setPortfolios([{ id: defaultPortfolioId, name: defaultPortfolioId }]);
}
};
fetchList();
}, [defaultPortfolioId, selectedId]);
// Fetch portfolio state when selection changes
const fetchState = useCallback(async () => {
if (!selectedId) return;
setLoading(true);
setError(null);
try {
const res = await axios.get(`${API_BASE}/portfolios/${selectedId}/latest`);
setState(res.data as PortfolioState);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load portfolio';
setError(msg);
setState(null);
} finally {
setLoading(false);
}
}, [selectedId]);
useEffect(() => {
fetchState();
}, [fetchState]);
return (
<Flex direction="column" h="100%" bg="slate.950" color="white" overflow="hidden">
{/* Header */}
<Flex p={4} bg="slate.900" borderBottom="1px solid" borderColor="whiteAlpha.100" align="center" gap={3}>
<Icon as={Wallet} color="cyan.400" boxSize={5} />
<Text fontWeight="bold" fontSize="lg">Portfolio Viewer</Text>
<Select
size="sm"
maxW="220px"
ml="auto"
bg="whiteAlpha.100"
borderColor="whiteAlpha.200"
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
>
{portfolios.map((p) => (
<option key={p.id} value={p.id} style={{ background: '#0f172a' }}>
{p.name || p.id}
</option>
))}
</Select>
<Box cursor="pointer" onClick={fetchState} opacity={0.6} _hover={{ opacity: 1 }}>
<RefreshCw size={16} />
</Box>
</Flex>
{/* Body */}
{loading && (
<Flex flex="1" align="center" justify="center"><Spinner color="cyan.400" /></Flex>
)}
{error && (
<Flex flex="1" align="center" justify="center" direction="column" gap={2} opacity={0.5}>
<Text fontSize="sm" color="red.300">{error}</Text>
<Text fontSize="xs">Make sure the backend is running and the portfolio exists.</Text>
</Flex>
)}
{!loading && !error && state && (
<Tabs variant="soft-rounded" colorScheme="cyan" size="sm" flex="1" display="flex" flexDirection="column" overflow="hidden">
<TabList px={4} pt={3}>
<Tab>Holdings ({state.holdings.length})</Tab>
<Tab>Trade History ({state.recent_trades.length})</Tab>
<Tab>Summary</Tab>
</TabList>
<TabPanels flex="1" overflow="auto">
{/* Holdings */}
<TabPanel px={4}>
{state.holdings.length === 0 ? (
<Text color="whiteAlpha.500" fontSize="sm" textAlign="center" mt={8}>No holdings found.</Text>
) : (
<Box overflowX="auto">
<Table size="sm" variant="unstyled">
<Thead>
<Tr>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100">Ticker</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Qty</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Avg Cost</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Mkt Value</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>P&L</Th>
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100">Sector</Th>
</Tr>
</Thead>
<Tbody>
{state.holdings.map((h, i) => {
const pnl = h.unrealized_pnl ?? 0;
return (
<Tr key={i} _hover={{ bg: 'whiteAlpha.50' }}>
<Td fontWeight="bold"><Code colorScheme="cyan" fontSize="sm">{h.ticker}</Code></Td>
<Td isNumeric>{h.quantity}</Td>
<Td isNumeric>${(h.avg_cost ?? 0).toFixed(2)}</Td>
<Td isNumeric>${(h.market_value ?? 0).toFixed(2)}</Td>
<Td isNumeric color={pnl >= 0 ? 'green.400' : 'red.400'}>
<HStack justify="flex-end" spacing={1}>
<Icon as={pnl >= 0 ? ArrowUpRight : ArrowDownRight} boxSize={3} />
<Text>${Math.abs(pnl).toFixed(2)}</Text>
</HStack>
</Td>
<Td><Badge variant="outline" fontSize="2xs">{h.sector || '—'}</Badge></Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
)}
</TabPanel>
{/* Trade History */}
<TabPanel px={4}>
{state.recent_trades.length === 0 ? (
<Text color="whiteAlpha.500" fontSize="sm" textAlign="center" mt={8}>No trades recorded yet.</Text>
) : (
<VStack align="stretch" spacing={2}>
{state.recent_trades.map((t, i) => (
<Flex
key={i}
bg="whiteAlpha.50"
p={3}
borderRadius="md"
border="1px solid"
borderColor="whiteAlpha.100"
justify="space-between"
align="center"
>
<HStack spacing={3}>
<Badge colorScheme={t.action?.toUpperCase() === 'BUY' ? 'green' : t.action?.toUpperCase() === 'SELL' ? 'red' : 'gray'}>
{t.action?.toUpperCase()}
</Badge>
<Code colorScheme="cyan" fontSize="sm">{t.ticker}</Code>
<Text fontSize="sm">{t.quantity} @ ${(t.price ?? 0).toFixed(2)}</Text>
</HStack>
<VStack align="flex-end" spacing={0}>
<Text fontSize="2xs" color="whiteAlpha.400">{t.executed_at || '—'}</Text>
{t.rationale && (
<Text fontSize="2xs" color="whiteAlpha.500" maxW="200px" isTruncated>{t.rationale}</Text>
)}
</VStack>
</Flex>
))}
</VStack>
)}
</TabPanel>
{/* Summary */}
<TabPanel px={4}>
<VStack align="stretch" spacing={3}>
<HStack>
<Text fontSize="sm" color="whiteAlpha.600" minW="100px">Portfolio ID:</Text>
<Code fontSize="sm">{state.portfolio.id}</Code>
</HStack>
{state.portfolio.cash_balance != null && (
<HStack>
<Text fontSize="sm" color="whiteAlpha.600" minW="100px">Cash Balance:</Text>
<Code colorScheme="green" fontSize="sm">${state.portfolio.cash_balance.toFixed(2)}</Code>
</HStack>
)}
{state.snapshot && (
<Box mt={2}>
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Latest Snapshot</Text>
<Box bg="blackAlpha.500" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="300px" overflowY="auto">
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
{JSON.stringify(state.snapshot, null, 2)}
</Text>
</Box>
</Box>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
)}
{!loading && !error && !state && (
<Flex flex="1" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
<Wallet size={48} />
<Text fontSize="sm">Select a portfolio to view</Text>
</Flex>
)}
</Flex>
);
};