mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
Add an AI-powered portfolio assistant that lets users ask natural-language questions about their holdings, run allocation simulations, and receive strategy recommendations via a terminal-themed chat UI. The agent runs as a separate microservice (available as an npm package). Ghostfolio proxies authenticated requests to it. The feature is fully optional — if AGENT_SERVICE_URL is not set, all endpoints return 503 and the UI route is inactive. Architecture: - NestJS proxy module (REST + WebSocket + SSE streaming) - Angular lazy-loaded chat page with real-time tool execution visibility - Strategy questionnaire component for guided portfolio recommendations - Zero impact on existing features when disabled Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>pull/6457/head
26 changed files with 3899 additions and 1 deletions
@ -0,0 +1,77 @@ |
|||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { AgentController } from './agent.controller'; |
||||
|
import { AgentService } from './agent.service'; |
||||
|
|
||||
|
describe('AgentController', () => { |
||||
|
const makeRes = () => { |
||||
|
const response = { |
||||
|
json: jest.fn(), |
||||
|
status: jest.fn() |
||||
|
}; |
||||
|
response.status.mockReturnValue(response); |
||||
|
return response; |
||||
|
}; |
||||
|
|
||||
|
it('returns 503 for auth status when agent service is disabled', async () => { |
||||
|
const request = { |
||||
|
headers: { |
||||
|
authorization: 'Bearer jwt-token' |
||||
|
}, |
||||
|
user: { |
||||
|
id: 'user-1' |
||||
|
} |
||||
|
} as unknown as RequestWithUser; |
||||
|
|
||||
|
const agentService = { |
||||
|
isEnabled: jest.fn().mockReturnValue(false), |
||||
|
proxyToAgent: jest.fn() |
||||
|
} as unknown as AgentService; |
||||
|
|
||||
|
const controller = new AgentController(agentService, request); |
||||
|
const res = makeRes(); |
||||
|
|
||||
|
await controller.getAuthStatus(res as any); |
||||
|
|
||||
|
expect(res.status).toHaveBeenCalledWith(503); |
||||
|
expect(res.json).toHaveBeenCalledWith({ |
||||
|
error: 'Agent service is not configured.' |
||||
|
}); |
||||
|
expect(agentService.proxyToAgent).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('proxies chat request with user id and bearer token', async () => { |
||||
|
const request = { |
||||
|
headers: { |
||||
|
authorization: 'Bearer jwt-token' |
||||
|
}, |
||||
|
user: { |
||||
|
id: 'user-1' |
||||
|
} |
||||
|
} as unknown as RequestWithUser; |
||||
|
|
||||
|
const agentService = { |
||||
|
isEnabled: jest.fn().mockReturnValue(true), |
||||
|
proxyToAgent: jest.fn().mockResolvedValue({ |
||||
|
status: 200, |
||||
|
data: { answer: 'done' } |
||||
|
}) |
||||
|
} as unknown as AgentService; |
||||
|
|
||||
|
const controller = new AgentController(agentService, request); |
||||
|
const res = makeRes(); |
||||
|
const body = { message: 'hello' }; |
||||
|
|
||||
|
await controller.chat(body, res as any); |
||||
|
|
||||
|
expect(agentService.proxyToAgent).toHaveBeenCalledWith({ |
||||
|
method: 'POST', |
||||
|
path: '/api/chat', |
||||
|
ghostfolioUserId: 'user-1', |
||||
|
bearerToken: 'Bearer jwt-token', |
||||
|
body |
||||
|
}); |
||||
|
expect(res.status).toHaveBeenCalledWith(200); |
||||
|
expect(res.json).toHaveBeenCalledWith({ answer: 'done' }); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,90 @@ |
|||||
|
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Get, |
||||
|
Inject, |
||||
|
Post, |
||||
|
Res, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { Response } from 'express'; |
||||
|
|
||||
|
import { AgentService } from './agent.service'; |
||||
|
|
||||
|
@Controller('agent') |
||||
|
export class AgentController { |
||||
|
public constructor( |
||||
|
private readonly agentService: AgentService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
private getBearerToken(): string { |
||||
|
const headers = this.request.headers as unknown as Record<string, unknown>; |
||||
|
const auth = |
||||
|
headers[HEADER_KEY_TOKEN.toLowerCase()] ?? headers['authorization']; |
||||
|
if (typeof auth === 'string' && auth.startsWith('Bearer ')) { |
||||
|
return auth; |
||||
|
} |
||||
|
return typeof auth === 'string' ? `Bearer ${auth}` : ''; |
||||
|
} |
||||
|
|
||||
|
@Get('auth/status') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async getAuthStatus(@Res() res: Response): Promise<void> { |
||||
|
if (!this.agentService.isEnabled()) { |
||||
|
res.status(503).json({ error: 'Agent service is not configured.' }); |
||||
|
return; |
||||
|
} |
||||
|
const { status, data } = await this.agentService.proxyToAgent({ |
||||
|
method: 'GET', |
||||
|
path: '/api/auth/status', |
||||
|
ghostfolioUserId: this.request.user.id, |
||||
|
bearerToken: this.getBearerToken() |
||||
|
}); |
||||
|
res.status(status).json(data); |
||||
|
} |
||||
|
|
||||
|
@Post('chat') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async chat( |
||||
|
@Body() body: unknown, |
||||
|
@Res() res: Response |
||||
|
): Promise<void> { |
||||
|
if (!this.agentService.isEnabled()) { |
||||
|
res.status(503).json({ error: 'Agent service is not configured.' }); |
||||
|
return; |
||||
|
} |
||||
|
const { status, data } = await this.agentService.proxyToAgent({ |
||||
|
method: 'POST', |
||||
|
path: '/api/chat', |
||||
|
ghostfolioUserId: this.request.user.id, |
||||
|
bearerToken: this.getBearerToken(), |
||||
|
body |
||||
|
}); |
||||
|
res.status(status).json(data); |
||||
|
} |
||||
|
|
||||
|
@Post('chat/stream') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async chatStream( |
||||
|
@Body() body: unknown, |
||||
|
@Res() res: Response |
||||
|
): Promise<void> { |
||||
|
if (!this.agentService.isEnabled()) { |
||||
|
res.status(503).json({ error: 'Agent service is not configured.' }); |
||||
|
return; |
||||
|
} |
||||
|
await this.agentService.proxyStreamToAgent({ |
||||
|
path: '/api/chat/stream', |
||||
|
ghostfolioUserId: this.request.user.id, |
||||
|
bearerToken: this.getBearerToken(), |
||||
|
body, |
||||
|
res |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,150 @@ |
|||||
|
import type { INestApplication } from '@nestjs/common'; |
||||
|
import { JwtService } from '@nestjs/jwt'; |
||||
|
import type { Server } from 'node:http'; |
||||
|
import { WebSocketServer } from 'ws'; |
||||
|
|
||||
|
import { AgentService } from './agent.service'; |
||||
|
|
||||
|
const AGENT_WS_PATH = '/api/v1/agent/ws'; |
||||
|
|
||||
|
export function setupAgentWebSocket( |
||||
|
httpServer: Server, |
||||
|
app: INestApplication |
||||
|
): void { |
||||
|
const agentService = app.get(AgentService); |
||||
|
const jwtService = app.get(JwtService); |
||||
|
|
||||
|
const wss = new WebSocketServer({ noServer: true }); |
||||
|
|
||||
|
httpServer.on('upgrade', (request, socket, head) => { |
||||
|
const url = request.url ?? ''; |
||||
|
if (!url.startsWith(AGENT_WS_PATH)) { |
||||
|
socket.destroy(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const parsed = new URL( |
||||
|
url, |
||||
|
`http://${request.headers.host ?? 'localhost'}` |
||||
|
); |
||||
|
const token = parsed.searchParams.get('token'); |
||||
|
if (!token) { |
||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); |
||||
|
socket.destroy(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const raw = token.startsWith('Bearer ') ? token.slice(7) : token; |
||||
|
let userId: string; |
||||
|
try { |
||||
|
const payload = jwtService.verify<{ id: string }>(raw); |
||||
|
userId = payload?.id; |
||||
|
if (!userId) { |
||||
|
throw new Error('Missing user id in token'); |
||||
|
} |
||||
|
} catch { |
||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); |
||||
|
socket.destroy(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const bearerToken = token.startsWith('Bearer ') ? token : `Bearer ${token}`; |
||||
|
|
||||
|
wss.handleUpgrade(request, socket, head, (ws) => { |
||||
|
wss.emit('connection', ws, request, { userId, bearerToken }); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
wss.on( |
||||
|
'connection', |
||||
|
( |
||||
|
ws: import('ws').WebSocket, |
||||
|
_request: unknown, |
||||
|
context: { userId: string; bearerToken: string } |
||||
|
) => { |
||||
|
const { userId, bearerToken } = context; |
||||
|
let abortController: AbortController | null = null; |
||||
|
|
||||
|
ws.on('message', async (data: Buffer | string) => { |
||||
|
if (ws.readyState !== 1) return; // OPEN
|
||||
|
|
||||
|
let msg: { |
||||
|
type: string; |
||||
|
conversationHistory?: { role: string; content: string }[]; |
||||
|
message?: string; |
||||
|
}; |
||||
|
try { |
||||
|
const raw = typeof data === 'string' ? data : data.toString('utf8'); |
||||
|
msg = JSON.parse(raw) as { |
||||
|
type: string; |
||||
|
conversationHistory?: { role: string; content: string }[]; |
||||
|
message?: string; |
||||
|
}; |
||||
|
} catch { |
||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' })); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (msg.type === 'cancel') { |
||||
|
abortController?.abort(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
msg.type !== 'chat' || |
||||
|
!msg.message || |
||||
|
!Array.isArray(msg.conversationHistory) |
||||
|
) { |
||||
|
ws.send( |
||||
|
JSON.stringify({ |
||||
|
type: 'error', |
||||
|
message: 'Expected { type: "chat", message, conversationHistory }' |
||||
|
}) |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!agentService.isEnabled()) { |
||||
|
ws.send( |
||||
|
JSON.stringify({ |
||||
|
type: 'error', |
||||
|
message: 'Agent service is not configured.' |
||||
|
}) |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
abortController = new AbortController(); |
||||
|
|
||||
|
try { |
||||
|
for await (const event of agentService.proxyStreamToAgentEvents({ |
||||
|
path: '/api/chat/stream', |
||||
|
ghostfolioUserId: userId, |
||||
|
bearerToken, |
||||
|
body: { |
||||
|
conversationHistory: msg.conversationHistory, |
||||
|
message: msg.message |
||||
|
}, |
||||
|
signal: abortController.signal |
||||
|
})) { |
||||
|
if (ws.readyState !== 1) break; |
||||
|
ws.send(JSON.stringify(event)); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
const name = (error as Error)?.name; |
||||
|
if (name === 'AbortError') return; |
||||
|
const msg = error instanceof Error ? error.message : String(error); |
||||
|
if (ws.readyState === 1) { |
||||
|
ws.send(JSON.stringify({ type: 'error', message: msg })); |
||||
|
} |
||||
|
} finally { |
||||
|
abortController = null; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
ws.on('close', () => { |
||||
|
abortController?.abort(); |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
import { JwtModule } from '@nestjs/jwt'; |
||||
|
|
||||
|
import { AgentController } from './agent.controller'; |
||||
|
import { AgentService } from './agent.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [AgentController], |
||||
|
imports: [ |
||||
|
ConfigurationModule, |
||||
|
JwtModule.register({ |
||||
|
secret: process.env.JWT_SECRET_KEY, |
||||
|
signOptions: { expiresIn: '180 days' } |
||||
|
}) |
||||
|
], |
||||
|
providers: [AgentService] |
||||
|
}) |
||||
|
export class AgentModule {} |
||||
@ -0,0 +1,89 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
|
||||
|
import { AgentService } from './agent.service'; |
||||
|
|
||||
|
describe('AgentService', () => { |
||||
|
let service: AgentService; |
||||
|
let configurationService: Pick<ConfigurationService, 'get'>; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = { |
||||
|
get: jest.fn() |
||||
|
}; |
||||
|
|
||||
|
service = new AgentService(configurationService as ConfigurationService); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
jest.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it('returns trimmed agent base URL', () => { |
||||
|
(configurationService.get as jest.Mock).mockReturnValue( |
||||
|
' http://localhost:3334/ ' |
||||
|
); |
||||
|
|
||||
|
expect(service.getAgentBaseUrl()).toBe('http://localhost:3334/'); |
||||
|
}); |
||||
|
|
||||
|
it('detects when the agent service is disabled', () => { |
||||
|
(configurationService.get as jest.Mock).mockReturnValue(' '); |
||||
|
|
||||
|
expect(service.isEnabled()).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('returns 503 when AGENT_SERVICE_URL is missing', async () => { |
||||
|
(configurationService.get as jest.Mock).mockReturnValue(''); |
||||
|
|
||||
|
await expect( |
||||
|
service.proxyToAgent({ |
||||
|
method: 'GET', |
||||
|
path: '/api/auth/status', |
||||
|
ghostfolioUserId: 'user-1', |
||||
|
bearerToken: 'Bearer abc' |
||||
|
}) |
||||
|
).resolves.toEqual({ |
||||
|
status: 503, |
||||
|
data: { |
||||
|
error: 'Agent service is not configured (AGENT_SERVICE_URL).' |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('forwards auth and user-id headers for POST requests', async () => { |
||||
|
(configurationService.get as jest.Mock).mockReturnValue( |
||||
|
'http://localhost:3334' |
||||
|
); |
||||
|
|
||||
|
const json = { answer: 'ok' }; |
||||
|
const fetchSpy = jest.spyOn(global, 'fetch' as any).mockResolvedValue({ |
||||
|
headers: { |
||||
|
get: () => 'application/json' |
||||
|
}, |
||||
|
json: async () => json, |
||||
|
status: 200 |
||||
|
} as unknown as Response); |
||||
|
|
||||
|
const payload = { message: 'hello' }; |
||||
|
|
||||
|
await expect( |
||||
|
service.proxyToAgent({ |
||||
|
method: 'POST', |
||||
|
path: '/api/chat', |
||||
|
ghostfolioUserId: 'user-123', |
||||
|
bearerToken: 'token-without-prefix', |
||||
|
body: payload |
||||
|
}) |
||||
|
).resolves.toEqual({ status: 200, data: json }); |
||||
|
|
||||
|
expect(fetchSpy).toHaveBeenCalledWith('http://localhost:3334/api/chat', { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
Authorization: 'Bearer token-without-prefix', |
||||
|
'x-ghostfolio-user-id': 'user-123' |
||||
|
}, |
||||
|
body: JSON.stringify(payload) |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,235 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
|
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import type { Response } from 'express'; |
||||
|
import { Readable } from 'node:stream'; |
||||
|
|
||||
|
const X_GHOSTFOLIO_USER_ID = 'x-ghostfolio-user-id'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class AgentService { |
||||
|
public constructor( |
||||
|
private readonly configurationService: ConfigurationService |
||||
|
) {} |
||||
|
|
||||
|
public getAgentBaseUrl(): string { |
||||
|
const url = this.configurationService.get('AGENT_SERVICE_URL'); |
||||
|
return typeof url === 'string' ? url.trim() : ''; |
||||
|
} |
||||
|
|
||||
|
public isEnabled(): boolean { |
||||
|
return this.getAgentBaseUrl().length > 0; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Proxy a request to the ghostfolio-agent service with shared-auth headers. |
||||
|
* Forwards the user's Ghostfolio JWT and user id so the agent uses the same auth. |
||||
|
*/ |
||||
|
public async proxyToAgent({ |
||||
|
method, |
||||
|
path, |
||||
|
ghostfolioUserId, |
||||
|
bearerToken, |
||||
|
body |
||||
|
}: { |
||||
|
method: 'GET' | 'POST'; |
||||
|
path: string; |
||||
|
ghostfolioUserId: string; |
||||
|
bearerToken: string; |
||||
|
body?: unknown; |
||||
|
}): Promise<{ status: number; data: unknown }> { |
||||
|
const baseUrl = this.getAgentBaseUrl(); |
||||
|
if (!baseUrl) { |
||||
|
return { |
||||
|
status: 503, |
||||
|
data: { error: 'Agent service is not configured (AGENT_SERVICE_URL).' } |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const url = `${baseUrl.replace(/\/$/, '')}${path}`; |
||||
|
const headers: Record<string, string> = { |
||||
|
'Content-Type': 'application/json', |
||||
|
Authorization: bearerToken.startsWith('Bearer ') |
||||
|
? bearerToken |
||||
|
: `Bearer ${bearerToken}`, |
||||
|
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId |
||||
|
}; |
||||
|
|
||||
|
const init: RequestInit = { method, headers }; |
||||
|
if (body !== undefined && method === 'POST') { |
||||
|
init.body = JSON.stringify(body); |
||||
|
} |
||||
|
|
||||
|
const response = await fetch(url, init); |
||||
|
let data: unknown; |
||||
|
const contentType = response.headers.get('content-type'); |
||||
|
if (contentType?.includes('application/json')) { |
||||
|
try { |
||||
|
data = await response.json(); |
||||
|
} catch { |
||||
|
data = { error: 'Invalid JSON response from agent' }; |
||||
|
} |
||||
|
} else { |
||||
|
data = { error: await response.text() }; |
||||
|
} |
||||
|
|
||||
|
return { status: response.status, data }; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Proxy a streaming request to the agent's /api/chat/stream endpoint. |
||||
|
* Pipes the SSE stream from the agent to the Express response. |
||||
|
*/ |
||||
|
public async proxyStreamToAgent({ |
||||
|
path, |
||||
|
ghostfolioUserId, |
||||
|
bearerToken, |
||||
|
body, |
||||
|
res |
||||
|
}: { |
||||
|
path: string; |
||||
|
ghostfolioUserId: string; |
||||
|
bearerToken: string; |
||||
|
body: unknown; |
||||
|
res: Response; |
||||
|
}): Promise<void> { |
||||
|
const baseUrl = this.getAgentBaseUrl(); |
||||
|
if (!baseUrl) { |
||||
|
res.status(503).json({ |
||||
|
error: 'Agent service is not configured (AGENT_SERVICE_URL).' |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const url = `${baseUrl.replace(/\/$/, '')}${path}`; |
||||
|
const headers: Record<string, string> = { |
||||
|
'Content-Type': 'application/json', |
||||
|
Authorization: bearerToken.startsWith('Bearer ') |
||||
|
? bearerToken |
||||
|
: `Bearer ${bearerToken}`, |
||||
|
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId |
||||
|
}; |
||||
|
|
||||
|
const agentRes = await fetch(url, { |
||||
|
method: 'POST', |
||||
|
headers, |
||||
|
body: JSON.stringify(body) |
||||
|
}); |
||||
|
|
||||
|
if (!agentRes.ok) { |
||||
|
const text = await agentRes.text(); |
||||
|
res |
||||
|
.status(agentRes.status) |
||||
|
.json({ error: text || 'Agent request failed' }); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
res.setHeader('Content-Type', 'text/event-stream'); |
||||
|
res.setHeader('Cache-Control', 'no-cache'); |
||||
|
res.setHeader('Connection', 'keep-alive'); |
||||
|
res.setHeader('X-Accel-Buffering', 'no'); |
||||
|
res.flushHeaders?.(); |
||||
|
// Disable Nagle's algorithm for immediate delivery of SSE chunks
|
||||
|
(res as any).socket?.setNoDelay?.(true); |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
const nodeStream = Readable.fromWeb(agentRes.body as any); |
||||
|
nodeStream.pipe(res); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Proxy a streaming request to the agent and yield parsed SSE events. |
||||
|
* Used by the WebSocket gateway to forward events to the client. |
||||
|
*/ |
||||
|
public async *proxyStreamToAgentEvents({ |
||||
|
path, |
||||
|
ghostfolioUserId, |
||||
|
bearerToken, |
||||
|
body, |
||||
|
signal |
||||
|
}: { |
||||
|
path: string; |
||||
|
ghostfolioUserId: string; |
||||
|
bearerToken: string; |
||||
|
body: unknown; |
||||
|
signal?: AbortSignal; |
||||
|
}): AsyncGenerator<Record<string, unknown>> { |
||||
|
const baseUrl = this.getAgentBaseUrl(); |
||||
|
if (!baseUrl) { |
||||
|
yield { |
||||
|
type: 'error', |
||||
|
message: 'Agent service is not configured (AGENT_SERVICE_URL).' |
||||
|
}; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const url = `${baseUrl.replace(/\/$/, '')}${path}`; |
||||
|
const headers: Record<string, string> = { |
||||
|
'Content-Type': 'application/json', |
||||
|
Authorization: bearerToken.startsWith('Bearer ') |
||||
|
? bearerToken |
||||
|
: `Bearer ${bearerToken}`, |
||||
|
[X_GHOSTFOLIO_USER_ID]: ghostfolioUserId |
||||
|
}; |
||||
|
|
||||
|
const agentRes = await fetch(url, { |
||||
|
method: 'POST', |
||||
|
headers, |
||||
|
body: JSON.stringify(body), |
||||
|
signal |
||||
|
}); |
||||
|
|
||||
|
if (!agentRes.ok) { |
||||
|
const text = await agentRes.text(); |
||||
|
yield { |
||||
|
type: 'error', |
||||
|
message: text || `Agent request failed (HTTP ${agentRes.status})` |
||||
|
}; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!agentRes.body) { |
||||
|
yield { type: 'error', message: 'Streaming response was empty' }; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const reader = agentRes.body.getReader(); |
||||
|
const decoder = new TextDecoder(); |
||||
|
let buffer = ''; |
||||
|
|
||||
|
try { |
||||
|
while (true) { |
||||
|
const { value, done } = await reader.read(); |
||||
|
if (done) break; |
||||
|
if (signal?.aborted) break; |
||||
|
|
||||
|
buffer += decoder.decode(value, { stream: true }); |
||||
|
|
||||
|
let separatorIndex = buffer.indexOf('\n\n'); |
||||
|
while (separatorIndex >= 0) { |
||||
|
const rawEvent = buffer.slice(0, separatorIndex); |
||||
|
buffer = buffer.slice(separatorIndex + 2); |
||||
|
|
||||
|
const dataPayload = rawEvent |
||||
|
.split('\n') |
||||
|
.filter((line) => line.startsWith('data:')) |
||||
|
.map((line) => line.slice(5).trim()) |
||||
|
.join('\n'); |
||||
|
|
||||
|
if (dataPayload) { |
||||
|
try { |
||||
|
const parsed = JSON.parse(dataPayload) as Record<string, unknown>; |
||||
|
yield parsed; |
||||
|
} catch { |
||||
|
// Skip malformed events
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
separatorIndex = buffer.indexOf('\n\n'); |
||||
|
} |
||||
|
} |
||||
|
} finally { |
||||
|
reader.releaseLock(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -0,0 +1,279 @@ |
|||||
|
<div class="agent-layout"> |
||||
|
<div class="chat-panel"> |
||||
|
<mat-card class="agent-shell terminal-theme"> |
||||
|
<mat-card-header class="terminal-header"> |
||||
|
<mat-card-title class="terminal-brand" i18n> |
||||
|
Agent |
||||
|
<span |
||||
|
class="connection-dot" |
||||
|
[ngClass]="connectionStatus" |
||||
|
[title]="connectionStatus" |
||||
|
></span> |
||||
|
</mat-card-title> |
||||
|
<mat-card-subtitle class="terminal-subtitle" i18n |
||||
|
>Ask portfolio questions using your current Ghostfolio |
||||
|
session.</mat-card-subtitle |
||||
|
> |
||||
|
</mat-card-header> |
||||
|
|
||||
|
<mat-card-content class="agent-content"> |
||||
|
<div #messagesContainer class="messages" role="log"> |
||||
|
@if (messages.length === 0) { |
||||
|
<div class="empty-state" i18n> |
||||
|
<div class="empty-text"> |
||||
|
Would you like me to buy stocks or crypto for you? |
||||
|
</div> |
||||
|
<button |
||||
|
class="strategy-flow-btn" |
||||
|
type="button" |
||||
|
(click)="startStrategyFlow()" |
||||
|
> |
||||
|
Build Investment Strategy |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@for (entry of messages; track trackByIndex($index)) { |
||||
|
<div |
||||
|
class="message-row" |
||||
|
[ngClass]="{ |
||||
|
assistant: entry.role === 'assistant', |
||||
|
user: entry.role === 'user' |
||||
|
}" |
||||
|
> |
||||
|
@if (entry.strategyStep) { |
||||
|
<gf-strategy-card |
||||
|
[disabled]=" |
||||
|
strategyMode && |
||||
|
currentStrategyStep?.stepId !== entry.strategyStep.stepId |
||||
|
" |
||||
|
[step]="entry.strategyStep" |
||||
|
(optionSelected)="handleStrategySelection($event)" |
||||
|
></gf-strategy-card> |
||||
|
} @else { |
||||
|
<div class="message-bubble"> |
||||
|
<div class="message-text"> |
||||
|
<markdown |
||||
|
class="message-markdown" |
||||
|
mermaid |
||||
|
[data]="entry.content ?? ''" |
||||
|
[mermaidOptions]="mermaidOptions" |
||||
|
></markdown> |
||||
|
</div> |
||||
|
@if ( |
||||
|
entry.role === 'assistant' && entry.confidence !== undefined |
||||
|
) { |
||||
|
<div class="meta-row"> |
||||
|
{{ entry.confidence * 100 | number: '1.0-0' }}% confidence |
||||
|
</div> |
||||
|
} |
||||
|
@if ( |
||||
|
entry.role === 'assistant' && |
||||
|
getDisplayWarnings(entry).length > 0 |
||||
|
) { |
||||
|
<div class="meta-row warning"> |
||||
|
{{ getDisplayWarnings(entry).join(' | ') }} |
||||
|
</div> |
||||
|
} |
||||
|
@if ( |
||||
|
entry.role === 'assistant' && |
||||
|
(entry.toolTrace?.length ?? 0) > 0 |
||||
|
) { |
||||
|
<div class="meta-row tool-trace"> |
||||
|
<span class="tool-trace-label" i18n>Tools</span> |
||||
|
@for ( |
||||
|
tool of getToolStatuses(entry); |
||||
|
track trackByIndex($index) |
||||
|
) { |
||||
|
<span |
||||
|
class="tool-pill" |
||||
|
[ngClass]="{ ok: tool.ok, fail: !tool.ok }" |
||||
|
> |
||||
|
{{ tool.tool }} {{ tool.ok ? 'ok' : 'failed' }} |
||||
|
</span> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
@if (entry.role === 'assistant' && entry.loopMeta) { |
||||
|
<div class="meta-row metrics-row"> |
||||
|
{{ getMetricsText(entry.loopMeta) }} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Thinking indicator --> |
||||
|
@if (isSending && thinkingLabel && streamingAnswer.length === 0) { |
||||
|
<div class="message-row assistant"> |
||||
|
<div class="thinking-indicator"> |
||||
|
<span class="thinking-dots"> |
||||
|
<span class="dot"></span> |
||||
|
<span class="dot"></span> |
||||
|
<span class="dot"></span> |
||||
|
</span> |
||||
|
<span class="thinking-label">{{ thinkingLabel }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Collapsible tool call rows --> |
||||
|
@if (activeToolCalls.length > 0) { |
||||
|
<div class="tool-calls-panel"> |
||||
|
@for (tc of activeToolCalls; track trackByIndex($index)) { |
||||
|
<div |
||||
|
class="tool-call-row" |
||||
|
[class.expanded]="tc.expanded" |
||||
|
[class.fail]="!tc.ok" |
||||
|
[class.ok]="tc.ok" |
||||
|
> |
||||
|
<div |
||||
|
class="tool-call-header" |
||||
|
(click)="toggleToolExpanded($index)" |
||||
|
> |
||||
|
<span class="tool-call-chevron">{{ |
||||
|
tc.expanded ? '▾' : '▸' |
||||
|
}}</span> |
||||
|
<span class="tool-call-name">{{ tc.tool }}</span> |
||||
|
@if (tc.ms > 0) { |
||||
|
<span |
||||
|
class="tool-call-status" |
||||
|
[class.fail]="!tc.ok" |
||||
|
[class.ok]="tc.ok" |
||||
|
> |
||||
|
{{ tc.ok ? tc.ms + 'ms' : 'FAIL' }} |
||||
|
</span> |
||||
|
} @else { |
||||
|
<span class="tool-call-status running">running</span> |
||||
|
} |
||||
|
</div> |
||||
|
@if (tc.expanded) { |
||||
|
<div class="tool-call-detail"> |
||||
|
@if (tc.input && (tc.input | json) !== '{}') { |
||||
|
<div class="tool-detail-section"> |
||||
|
<span class="tool-detail-label">Input</span> |
||||
|
<pre class="tool-detail-pre">{{ |
||||
|
tc.input | json |
||||
|
}}</pre> |
||||
|
</div> |
||||
|
} |
||||
|
@if (tc.result) { |
||||
|
<div class="tool-detail-section"> |
||||
|
<span class="tool-detail-label">Result</span> |
||||
|
<pre class="tool-detail-pre">{{ |
||||
|
tc.result | json |
||||
|
}}</pre> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Debug console (toggleable) --> |
||||
|
@if (showDebugConsole && streamLogLines.length > 0) { |
||||
|
<div class="message-row assistant stream-console"> |
||||
|
<div class="stream-bubble"> |
||||
|
<div class="stream-header">[AGENT]</div> |
||||
|
<div class="stream-lines"> |
||||
|
@for (line of streamLogLines; track trackByIndex($index)) { |
||||
|
<div class="stream-line" [ngClass]="line.cssClass"> |
||||
|
{{ line.text }} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Streaming answer with blinking cursor --> |
||||
|
@if (streamingAnswer.length > 0) { |
||||
|
<div class="message-row assistant streaming-answer"> |
||||
|
<div class="message-bubble"> |
||||
|
<div class="message-text"> |
||||
|
@if (receivedTextDelta) { |
||||
|
<markdown |
||||
|
class="message-markdown" |
||||
|
mermaid |
||||
|
[data]="streamingAnswer" |
||||
|
[mermaidOptions]="mermaidOptions" |
||||
|
></markdown> |
||||
|
} @else { |
||||
|
<span class="streaming-text">{{ |
||||
|
streamingAnswer.slice(0, streamingAnswerVisibleLength) |
||||
|
}}</span> |
||||
|
} |
||||
|
@if (isSending) { |
||||
|
<span aria-hidden="true" class="typewriter-cursor"></span> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
<div #scrollAnchor aria-hidden="true" class="scroll-anchor"></div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="composer terminal-composer"> |
||||
|
<span aria-hidden="true" class="prompt-char">></span> |
||||
|
<mat-form-field appearance="outline" class="composer-input"> |
||||
|
<input |
||||
|
i18n-placeholder |
||||
|
matInput |
||||
|
placeholder="Type your question..." |
||||
|
[(ngModel)]="draftMessage" |
||||
|
(keyup.enter)="sendMessage()" |
||||
|
/> |
||||
|
</mat-form-field> |
||||
|
|
||||
|
@if (isSending && hasDraftMessage) { |
||||
|
<button |
||||
|
class="queue-btn terminal-queue" |
||||
|
i18n |
||||
|
type="button" |
||||
|
(click)="sendMessage()" |
||||
|
> |
||||
|
Queue |
||||
|
</button> |
||||
|
<button |
||||
|
class="queue-btn terminal-interject" |
||||
|
i18n |
||||
|
type="button" |
||||
|
(click)="interjectMessage()" |
||||
|
> |
||||
|
Interject |
||||
|
</button> |
||||
|
} |
||||
|
|
||||
|
<button |
||||
|
class="debug-toggle-btn" |
||||
|
title="Toggle debug console" |
||||
|
type="button" |
||||
|
[class.active]="showDebugConsole" |
||||
|
(click)="toggleDebugConsole()" |
||||
|
> |
||||
|
<span aria-hidden="true">{{ '{' }}{{ '}' }}</span> |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
class="send-btn terminal-send" |
||||
|
type="button" |
||||
|
[disabled]="!isSending && !hasDraftMessage" |
||||
|
[ngClass]="{ stop: isSending }" |
||||
|
(click)="isSending ? cancelMessage() : sendMessage()" |
||||
|
> |
||||
|
<span aria-hidden="true" class="send-glyph">{{ |
||||
|
isSending ? '■' : '>' |
||||
|
}}</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
@if (queueCount > 0) { |
||||
|
<div class="queue-meta" i18n>{{ queueCount }} queued</div> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,15 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
import { internalRoutes } from '@ghostfolio/common/routes/routes'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { GfAgentPageComponent } from './agent-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: GfAgentPageComponent, |
||||
|
path: '', |
||||
|
title: internalRoutes.agent.title |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,708 @@ |
|||||
|
// Bloomberg terminal-style theme for the agent tab |
||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
// --- Layout: chat + chart side by side --- |
||||
|
|
||||
|
.agent-layout { |
||||
|
display: flex; |
||||
|
gap: 0; |
||||
|
height: calc(100vh - 8.5rem); |
||||
|
margin: 1rem auto; |
||||
|
max-width: 90rem; |
||||
|
} |
||||
|
|
||||
|
.chat-panel { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 768px) { |
||||
|
.agent-layout { |
||||
|
flex-direction: column; |
||||
|
height: auto; |
||||
|
min-height: calc(100vh - 8.5rem); |
||||
|
} |
||||
|
|
||||
|
.chat-panel { |
||||
|
min-height: 60vh; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.agent-shell.terminal-theme { |
||||
|
background: #0a0a0a; |
||||
|
border: 1px solid #333; |
||||
|
border-radius: 0; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .terminal-header { |
||||
|
align-items: flex-start; |
||||
|
background: #111; |
||||
|
border-bottom: 2px solid #ff6600; |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
justify-content: space-between; |
||||
|
padding: 0.75rem 1rem; |
||||
|
|
||||
|
.terminal-brand { |
||||
|
align-items: center; |
||||
|
color: #ff6600; |
||||
|
display: flex; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 1rem; |
||||
|
font-weight: 700; |
||||
|
gap: 0.5rem; |
||||
|
letter-spacing: 2px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.header-actions { |
||||
|
display: flex; |
||||
|
gap: 0.4rem; |
||||
|
} |
||||
|
|
||||
|
.connection-dot { |
||||
|
border-radius: 50%; |
||||
|
display: inline-block; |
||||
|
flex-shrink: 0; |
||||
|
height: 8px; |
||||
|
width: 8px; |
||||
|
|
||||
|
&.connected { |
||||
|
background: #33ff99; |
||||
|
box-shadow: 0 0 4px rgba(51, 255, 153, 0.5); |
||||
|
} |
||||
|
|
||||
|
&.connecting { |
||||
|
animation: dot-pulse 1.2s ease-in-out infinite; |
||||
|
background: #ffb84d; |
||||
|
box-shadow: 0 0 4px rgba(255, 184, 77, 0.5); |
||||
|
} |
||||
|
|
||||
|
&.disconnected, |
||||
|
&.error { |
||||
|
background: #ff6b6b; |
||||
|
box-shadow: 0 0 4px rgba(255, 107, 107, 0.5); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes dot-pulse { |
||||
|
0%, |
||||
|
100% { |
||||
|
opacity: 1; |
||||
|
} |
||||
|
50% { |
||||
|
opacity: 0.3; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-subtitle { |
||||
|
color: #666; |
||||
|
font-size: 0.7rem; |
||||
|
letter-spacing: 1px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .agent-content { |
||||
|
display: flex; |
||||
|
flex: 1; |
||||
|
flex-direction: column; |
||||
|
gap: 0; |
||||
|
min-height: 0; |
||||
|
padding: 0; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .messages { |
||||
|
background: #0a0a0a; |
||||
|
border: none; |
||||
|
border-radius: 0; |
||||
|
flex: 1; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 13px; |
||||
|
min-height: 0; |
||||
|
overflow: auto; |
||||
|
overflow-anchor: auto; |
||||
|
padding: 0.75rem 1rem; |
||||
|
scroll-behavior: auto; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .scroll-anchor { |
||||
|
height: 1px; |
||||
|
width: 100%; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .empty-state { |
||||
|
color: #666; |
||||
|
margin: auto; |
||||
|
text-align: center; |
||||
|
|
||||
|
.empty-text { |
||||
|
font-size: 0.75rem; |
||||
|
letter-spacing: 1px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .message-row { |
||||
|
display: flex; |
||||
|
padding: 6px 0; |
||||
|
border-bottom: 1px solid #1a1a1a; |
||||
|
|
||||
|
&:last-child { |
||||
|
border-bottom: none; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .message-row.user { |
||||
|
justify-content: flex-end; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .message-bubble { |
||||
|
background: transparent; |
||||
|
border-radius: 0; |
||||
|
max-width: 85%; |
||||
|
padding: 0.4rem 0; |
||||
|
white-space: pre-wrap; |
||||
|
word-break: break-word; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .message-text { |
||||
|
color: #d9d9d9; |
||||
|
font-size: 0.86rem; |
||||
|
line-height: 1.6; |
||||
|
|
||||
|
// Structured table styling for markdown tables |
||||
|
table { |
||||
|
border-collapse: collapse; |
||||
|
font-size: 0.82rem; |
||||
|
margin: 0.75rem 0; |
||||
|
width: 100%; |
||||
|
max-width: 100%; |
||||
|
} |
||||
|
|
||||
|
th, |
||||
|
td { |
||||
|
border: 1px solid #333; |
||||
|
padding: 0.45rem 0.65rem; |
||||
|
text-align: left; |
||||
|
} |
||||
|
|
||||
|
th { |
||||
|
background: #1a1a1a; |
||||
|
color: #ff6600; |
||||
|
font-weight: 600; |
||||
|
letter-spacing: 0.5px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
td { |
||||
|
color: #c8c8c8; |
||||
|
} |
||||
|
|
||||
|
// Right-align numeric columns (2nd column onward, typically metric values) |
||||
|
td:not(:first-child) { |
||||
|
text-align: right; |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
} |
||||
|
|
||||
|
tr:nth-child(even) td { |
||||
|
background: rgba(255, 255, 255, 0.02); |
||||
|
} |
||||
|
|
||||
|
tr:hover td { |
||||
|
background: rgba(255, 102, 0, 0.06); |
||||
|
} |
||||
|
|
||||
|
// Inline Mermaid charts |
||||
|
.mermaid { |
||||
|
margin: 0.75rem 0; |
||||
|
max-width: 100%; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .message-row.assistant .message-text { |
||||
|
color: #c8c8c8; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-console .stream-bubble { |
||||
|
background: #0d0d0d; |
||||
|
border: 1px solid #333; |
||||
|
border-radius: 0; |
||||
|
max-width: 100%; |
||||
|
padding: 0.5rem 0.75rem; |
||||
|
font-size: 0.78rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-header { |
||||
|
color: #ff6600; |
||||
|
font-weight: 700; |
||||
|
letter-spacing: 1px; |
||||
|
margin-bottom: 0.35rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-lines { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 0.15rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-line { |
||||
|
color: #888; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-line.stream-thinking { |
||||
|
color: #6b8cae; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-line.stream-tool-running { |
||||
|
color: #ffb84d; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-line.stream-tool-ok { |
||||
|
color: #33ff99; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-line.stream-tool-fail, |
||||
|
.terminal-theme .stream-line.stream-tool-blocked { |
||||
|
color: #ff6b6b; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-line.stream-done { |
||||
|
color: #33ff99; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-line.stream-metrics { |
||||
|
color: #888; |
||||
|
font-size: 0.72rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .stream-line.stream-fail { |
||||
|
color: #ff6b6b; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .streaming-answer .message-text { |
||||
|
white-space: pre-wrap; |
||||
|
word-break: break-word; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .streaming-text { |
||||
|
white-space: pre-wrap; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .typewriter-cursor { |
||||
|
display: inline-block; |
||||
|
width: 2px; |
||||
|
height: 1em; |
||||
|
background: #ff6600; |
||||
|
margin-left: 1px; |
||||
|
animation: cursor-blink 1s step-end infinite; |
||||
|
vertical-align: text-bottom; |
||||
|
} |
||||
|
|
||||
|
@keyframes cursor-blink { |
||||
|
50% { |
||||
|
opacity: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .message-row.user .message-text { |
||||
|
color: #ff9900; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .meta-row { |
||||
|
color: #5b5b5b; |
||||
|
font-size: 0.65rem; |
||||
|
margin-top: 0.35rem; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.5px; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .meta-row.warning { |
||||
|
color: #ff3333; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .meta-row.metrics-row { |
||||
|
color: #6b8cae; |
||||
|
font-size: 0.68rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-trace { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 0.3rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-trace-label { |
||||
|
color: #999; |
||||
|
margin-right: 0.2rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-pill { |
||||
|
border: 1px solid #333; |
||||
|
border-radius: 2px; |
||||
|
color: #aaa; |
||||
|
display: inline-block; |
||||
|
font-size: 0.62rem; |
||||
|
letter-spacing: 0.4px; |
||||
|
padding: 0.1rem 0.3rem; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-pill.ok { |
||||
|
border-color: #1f5f3a; |
||||
|
color: #33ff99; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-pill.fail { |
||||
|
border-color: #733; |
||||
|
color: #ff6b6b; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 768px) { |
||||
|
.terminal-theme .tool-trace { |
||||
|
gap: 0.2rem; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .composer.terminal-composer { |
||||
|
align-items: center; |
||||
|
display: flex; |
||||
|
gap: 0; |
||||
|
background: #111; |
||||
|
border: 1px solid #333; |
||||
|
border-top: 1px solid #333; |
||||
|
padding: 0; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .prompt-char { |
||||
|
color: #ff6600; |
||||
|
font-weight: 700; |
||||
|
font-size: 14px; |
||||
|
padding: 10px 0 10px 12px; |
||||
|
user-select: none; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .composer-input { |
||||
|
flex: 1; |
||||
|
|
||||
|
::ng-deep { |
||||
|
.mat-mdc-form-field-subscript-wrapper { |
||||
|
display: none; |
||||
|
} |
||||
|
.mat-mdc-text-field-wrapper { |
||||
|
background: transparent; |
||||
|
} |
||||
|
.mdc-notched-outline .mdc-notched-outline__leading, |
||||
|
.mdc-notched-outline .mdc-notched-outline__notch, |
||||
|
.mdc-notched-outline .mdc-notched-outline__trailing { |
||||
|
border-color: transparent; |
||||
|
} |
||||
|
.mat-mdc-form-field-focus-overlay { |
||||
|
background: transparent; |
||||
|
} |
||||
|
input { |
||||
|
color: #ff9900; |
||||
|
caret-color: #ff6600; |
||||
|
} |
||||
|
input::placeholder { |
||||
|
color: #444; |
||||
|
text-transform: uppercase; |
||||
|
font-size: 0.7rem; |
||||
|
letter-spacing: 0.5px; |
||||
|
} |
||||
|
.mat-mdc-floating-label { |
||||
|
color: #666; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .send-btn.terminal-send { |
||||
|
background: #ff6600; |
||||
|
border: none; |
||||
|
color: #000; |
||||
|
min-width: 44px; |
||||
|
padding: 10px 12px; |
||||
|
cursor: pointer; |
||||
|
font-size: 0.9rem; |
||||
|
font-weight: 700; |
||||
|
line-height: 1; |
||||
|
flex-shrink: 0; |
||||
|
|
||||
|
&:hover:not(:disabled) { |
||||
|
background: #ff8533; |
||||
|
} |
||||
|
|
||||
|
&:disabled { |
||||
|
opacity: 0.5; |
||||
|
cursor: not-allowed; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .send-btn.terminal-send.stop { |
||||
|
background: #c74d00; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .send-glyph { |
||||
|
display: inline-block; |
||||
|
font-size: 1rem; |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .queue-btn { |
||||
|
border: 1px solid #444; |
||||
|
background: #111; |
||||
|
color: #aaa; |
||||
|
cursor: pointer; |
||||
|
font-size: 0.62rem; |
||||
|
letter-spacing: 0.6px; |
||||
|
padding: 0.45rem 0.55rem; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .queue-btn.terminal-queue:hover { |
||||
|
border-color: #ff8533; |
||||
|
color: #ff8533; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .queue-btn.terminal-interject:hover { |
||||
|
border-color: #33ff99; |
||||
|
color: #33ff99; |
||||
|
} |
||||
|
|
||||
|
// --- Thinking indicator --- |
||||
|
|
||||
|
.terminal-theme .thinking-indicator { |
||||
|
align-items: center; |
||||
|
display: flex; |
||||
|
gap: 0.5rem; |
||||
|
padding: 0.5rem 0; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .thinking-dots { |
||||
|
display: inline-flex; |
||||
|
gap: 4px; |
||||
|
|
||||
|
.dot { |
||||
|
animation: dot-bounce 1.4s ease-in-out infinite; |
||||
|
background: #ff6600; |
||||
|
border-radius: 50%; |
||||
|
display: inline-block; |
||||
|
height: 6px; |
||||
|
width: 6px; |
||||
|
|
||||
|
&:nth-child(2) { |
||||
|
animation-delay: 0.2s; |
||||
|
} |
||||
|
|
||||
|
&:nth-child(3) { |
||||
|
animation-delay: 0.4s; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes dot-bounce { |
||||
|
0%, |
||||
|
80%, |
||||
|
100% { |
||||
|
opacity: 0.3; |
||||
|
transform: scale(0.8); |
||||
|
} |
||||
|
40% { |
||||
|
opacity: 1; |
||||
|
transform: scale(1); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .thinking-label { |
||||
|
color: #6b8cae; |
||||
|
font-size: 0.75rem; |
||||
|
letter-spacing: 0.5px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
// --- Collapsible tool call rows --- |
||||
|
|
||||
|
.terminal-theme .tool-calls-panel { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 2px; |
||||
|
margin: 0.4rem 0; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-call-row { |
||||
|
background: #111; |
||||
|
border-left: 2px solid #333; |
||||
|
border-radius: 0; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.75rem; |
||||
|
|
||||
|
&.ok { |
||||
|
border-left-color: #33ff99; |
||||
|
} |
||||
|
|
||||
|
&.fail { |
||||
|
border-left-color: #ff6b6b; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-call-header { |
||||
|
align-items: center; |
||||
|
cursor: pointer; |
||||
|
display: flex; |
||||
|
gap: 0.4rem; |
||||
|
padding: 0.35rem 0.6rem; |
||||
|
user-select: none; |
||||
|
|
||||
|
&:hover { |
||||
|
background: rgba(255, 255, 255, 0.03); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-call-chevron { |
||||
|
color: #666; |
||||
|
flex-shrink: 0; |
||||
|
font-size: 0.7rem; |
||||
|
width: 0.8rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-call-name { |
||||
|
color: #ff6600; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-call-status { |
||||
|
color: #888; |
||||
|
margin-left: auto; |
||||
|
|
||||
|
&.ok { |
||||
|
color: #33ff99; |
||||
|
} |
||||
|
|
||||
|
&.fail { |
||||
|
color: #ff6b6b; |
||||
|
} |
||||
|
|
||||
|
&.running { |
||||
|
animation: dot-pulse 1.2s ease-in-out infinite; |
||||
|
color: #ffb84d; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-call-detail { |
||||
|
border-top: 1px solid #222; |
||||
|
padding: 0.3rem 0.6rem 0.4rem 1.6rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-detail-section { |
||||
|
margin-bottom: 0.3rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-detail-label { |
||||
|
color: #666; |
||||
|
display: block; |
||||
|
font-size: 0.65rem; |
||||
|
letter-spacing: 0.3px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .tool-detail-pre { |
||||
|
color: #999; |
||||
|
font-size: 0.68rem; |
||||
|
line-height: 1.4; |
||||
|
margin: 0.15rem 0 0; |
||||
|
max-height: 120px; |
||||
|
overflow: auto; |
||||
|
white-space: pre-wrap; |
||||
|
word-break: break-all; |
||||
|
} |
||||
|
|
||||
|
// --- Debug console toggle --- |
||||
|
|
||||
|
.terminal-theme .debug-toggle-btn { |
||||
|
background: transparent; |
||||
|
border: 1px solid #333; |
||||
|
border-radius: 2px; |
||||
|
color: #666; |
||||
|
cursor: pointer; |
||||
|
flex-shrink: 0; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.7rem; |
||||
|
padding: 0.35rem 0.4rem; |
||||
|
|
||||
|
&:hover { |
||||
|
border-color: #555; |
||||
|
color: #aaa; |
||||
|
} |
||||
|
|
||||
|
&.active { |
||||
|
border-color: #ff6600; |
||||
|
color: #ff6600; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .queue-meta { |
||||
|
color: #777; |
||||
|
font-size: 0.62rem; |
||||
|
letter-spacing: 0.4px; |
||||
|
margin: 0.25rem 0.5rem 0.4rem; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
// --- Strategy flow button in empty state --- |
||||
|
|
||||
|
.terminal-theme .empty-state { |
||||
|
align-items: center; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 1rem; |
||||
|
} |
||||
|
|
||||
|
.terminal-theme .strategy-flow-btn { |
||||
|
background: transparent; |
||||
|
border: 1px solid #ff6600; |
||||
|
color: #ff6600; |
||||
|
cursor: pointer; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.72rem; |
||||
|
letter-spacing: 0.8px; |
||||
|
padding: 0.55rem 1rem; |
||||
|
text-transform: uppercase; |
||||
|
transition: |
||||
|
background 0.15s, |
||||
|
color 0.15s; |
||||
|
|
||||
|
&:hover { |
||||
|
background: #ff6600; |
||||
|
color: #000; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Strategy card within messages --- |
||||
|
|
||||
|
.terminal-theme .message-row gf-strategy-card { |
||||
|
max-width: 85%; |
||||
|
width: 100%; |
||||
|
} |
||||
@ -0,0 +1,47 @@ |
|||||
|
<div class="chart-panel-container"> |
||||
|
<div class="metrics-bar"> |
||||
|
<div class="metric"> |
||||
|
<span class="metric-label">TOTAL VALUE</span> |
||||
|
<span class="metric-value">${{ currentValue | number: '1.2-2' }}</span> |
||||
|
</div> |
||||
|
<div class="metric"> |
||||
|
<span class="metric-label">RETURN</span> |
||||
|
<span |
||||
|
class="metric-value" |
||||
|
[class.negative]="returnPct < 0" |
||||
|
[class.positive]="returnPct >= 0" |
||||
|
> |
||||
|
{{ returnPct >= 0 ? '+' : '' }}{{ returnPct * 100 | number: '1.2-2' }}% |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="metric"> |
||||
|
<span class="metric-label">TODAY</span> |
||||
|
<span |
||||
|
class="metric-value" |
||||
|
[class.negative]="todayChange < 0" |
||||
|
[class.positive]="todayChange >= 0" |
||||
|
> |
||||
|
{{ todayChange >= 0 ? '+' : '' }}{{ todayChange | number: '1.2-2' }}% |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="chart-wrapper"> |
||||
|
@if (isLoading) { |
||||
|
<div class="chart-loading">Loading...</div> |
||||
|
} |
||||
|
<canvas #chartCanvas></canvas> |
||||
|
</div> |
||||
|
|
||||
|
<div class="range-selector"> |
||||
|
@for (r of ranges; track r.value) { |
||||
|
<button |
||||
|
class="range-pill" |
||||
|
[class.active]="range === r.value" |
||||
|
(click)="setRange(r.value)" |
||||
|
> |
||||
|
{{ r.label }} |
||||
|
</button> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,107 @@ |
|||||
|
.chart-panel-container { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 0.5rem; |
||||
|
height: 100%; |
||||
|
padding: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
.metrics-bar { |
||||
|
display: flex; |
||||
|
gap: 1rem; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
|
|
||||
|
.metric { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 0.15rem; |
||||
|
} |
||||
|
|
||||
|
.metric-label { |
||||
|
color: #666; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.6rem; |
||||
|
letter-spacing: 0.5px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.metric-value { |
||||
|
color: #d9d9d9; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.85rem; |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
font-weight: 600; |
||||
|
|
||||
|
&.positive { |
||||
|
color: #33ff99; |
||||
|
} |
||||
|
|
||||
|
&.negative { |
||||
|
color: #ff6b6b; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.chart-wrapper { |
||||
|
flex: 1; |
||||
|
min-height: 0; |
||||
|
position: relative; |
||||
|
|
||||
|
canvas { |
||||
|
height: 100% !important; |
||||
|
width: 100% !important; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.chart-loading { |
||||
|
align-items: center; |
||||
|
color: #666; |
||||
|
display: flex; |
||||
|
font-family: monospace; |
||||
|
font-size: 0.7rem; |
||||
|
height: 100%; |
||||
|
justify-content: center; |
||||
|
left: 0; |
||||
|
letter-spacing: 1px; |
||||
|
position: absolute; |
||||
|
text-transform: uppercase; |
||||
|
top: 0; |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.range-selector { |
||||
|
display: flex; |
||||
|
gap: 4px; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.range-pill { |
||||
|
background: #1a1a1a; |
||||
|
border: 1px solid #333; |
||||
|
border-radius: 2px; |
||||
|
color: #888; |
||||
|
cursor: pointer; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.62rem; |
||||
|
letter-spacing: 0.5px; |
||||
|
padding: 0.25rem 0.5rem; |
||||
|
text-transform: uppercase; |
||||
|
|
||||
|
&:hover { |
||||
|
border-color: #555; |
||||
|
color: #ccc; |
||||
|
} |
||||
|
|
||||
|
&.active { |
||||
|
background: #ff6600; |
||||
|
border-color: #ff6600; |
||||
|
color: #000; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,237 @@ |
|||||
|
import { DateRange } from '@ghostfolio/common/types'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { CommonModule, DecimalPipe } from '@angular/common'; |
||||
|
import { |
||||
|
AfterViewInit, |
||||
|
Component, |
||||
|
ElementRef, |
||||
|
EventEmitter, |
||||
|
Input, |
||||
|
OnChanges, |
||||
|
OnDestroy, |
||||
|
Output, |
||||
|
SimpleChanges, |
||||
|
ViewChild |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
CategoryScale, |
||||
|
Chart, |
||||
|
Filler, |
||||
|
LinearScale, |
||||
|
LineController, |
||||
|
LineElement, |
||||
|
PointElement, |
||||
|
TimeScale, |
||||
|
Tooltip |
||||
|
} from 'chart.js'; |
||||
|
import 'chartjs-adapter-date-fns'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
import { takeUntil } from 'rxjs/operators'; |
||||
|
|
||||
|
Chart.register( |
||||
|
CategoryScale, |
||||
|
Filler, |
||||
|
LinearScale, |
||||
|
LineController, |
||||
|
LineElement, |
||||
|
PointElement, |
||||
|
TimeScale, |
||||
|
Tooltip |
||||
|
); |
||||
|
|
||||
|
interface RangeOption { |
||||
|
label: string; |
||||
|
value: DateRange; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
imports: [CommonModule], |
||||
|
providers: [DecimalPipe], |
||||
|
selector: 'gf-agent-chart-panel', |
||||
|
styleUrls: ['./agent-chart-panel.component.scss'], |
||||
|
templateUrl: './agent-chart-panel.component.html' |
||||
|
}) |
||||
|
export class GfAgentChartPanelComponent |
||||
|
implements AfterViewInit, OnChanges, OnDestroy |
||||
|
{ |
||||
|
@Input() range: DateRange = 'max'; |
||||
|
@Output() rangeChange = new EventEmitter<DateRange>(); |
||||
|
|
||||
|
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>; |
||||
|
|
||||
|
public currentValue = 0; |
||||
|
public isLoading = false; |
||||
|
public returnPct = 0; |
||||
|
public todayChange = 0; |
||||
|
|
||||
|
public ranges: RangeOption[] = [ |
||||
|
{ label: '1W', value: 'wtd' }, |
||||
|
{ label: '1M', value: 'mtd' }, |
||||
|
{ label: '3M', value: '3m' }, |
||||
|
{ label: '6M', value: '6m' }, |
||||
|
{ label: 'YTD', value: 'ytd' }, |
||||
|
{ label: '1Y', value: '1y' }, |
||||
|
{ label: 'ALL', value: 'max' } |
||||
|
]; |
||||
|
|
||||
|
private chart: Chart | null = null; |
||||
|
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
|
public constructor( |
||||
|
private dataService: DataService, |
||||
|
private decimalPipe: DecimalPipe |
||||
|
) {} |
||||
|
|
||||
|
public ngAfterViewInit(): void { |
||||
|
this.loadData(); |
||||
|
} |
||||
|
|
||||
|
public ngOnChanges(changes: SimpleChanges): void { |
||||
|
if (changes['range'] && !changes['range'].firstChange) { |
||||
|
this.loadData(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public setRange(range: DateRange): void { |
||||
|
this.range = range; |
||||
|
this.rangeChange.emit(range); |
||||
|
this.loadData(); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy(): void { |
||||
|
this.chart?.destroy(); |
||||
|
this.unsubscribeSubject.next(); |
||||
|
this.unsubscribeSubject.complete(); |
||||
|
} |
||||
|
|
||||
|
private loadData(): void { |
||||
|
this.isLoading = true; |
||||
|
|
||||
|
this.dataService |
||||
|
.fetchPortfolioPerformance({ range: this.range, withItems: true }) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe({ |
||||
|
next: (response) => { |
||||
|
this.isLoading = false; |
||||
|
|
||||
|
const perf = response.performance; |
||||
|
this.currentValue = |
||||
|
perf?.currentNetWorth ?? perf?.currentValueInBaseCurrency ?? 0; |
||||
|
this.returnPct = perf?.netPerformancePercentage ?? 0; |
||||
|
|
||||
|
// Approximate today's change from last two chart points
|
||||
|
const chartData = response.chart ?? []; |
||||
|
if (chartData.length >= 2) { |
||||
|
const last = chartData[chartData.length - 1]?.netWorth ?? 0; |
||||
|
const prev = chartData[chartData.length - 2]?.netWorth ?? 0; |
||||
|
this.todayChange = prev > 0 ? ((last - prev) / prev) * 100 : 0; |
||||
|
} else { |
||||
|
this.todayChange = 0; |
||||
|
} |
||||
|
|
||||
|
this.renderChart(chartData); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private renderChart( |
||||
|
data: { |
||||
|
date: string; |
||||
|
netWorth?: number; |
||||
|
netPerformanceInPercentage?: number; |
||||
|
}[] |
||||
|
): void { |
||||
|
const canvas = this.chartCanvas?.nativeElement; |
||||
|
if (!canvas) return; |
||||
|
|
||||
|
this.chart?.destroy(); |
||||
|
|
||||
|
const ctx = canvas.getContext('2d'); |
||||
|
if (!ctx) return; |
||||
|
|
||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); |
||||
|
gradient.addColorStop(0, 'rgba(255, 102, 0, 0.15)'); |
||||
|
gradient.addColorStop(1, 'rgba(255, 102, 0, 0)'); |
||||
|
|
||||
|
const labels = data.map((d) => d.date); |
||||
|
const values = data.map((d) => d.netWorth ?? 0); |
||||
|
|
||||
|
this.chart = new Chart(ctx, { |
||||
|
type: 'line', |
||||
|
data: { |
||||
|
labels, |
||||
|
datasets: [ |
||||
|
{ |
||||
|
data: values, |
||||
|
borderColor: '#ff6600', |
||||
|
borderWidth: 1.5, |
||||
|
backgroundColor: gradient, |
||||
|
fill: true, |
||||
|
pointRadius: 0, |
||||
|
pointHitRadius: 8, |
||||
|
tension: 0.3 |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
options: { |
||||
|
responsive: true, |
||||
|
maintainAspectRatio: false, |
||||
|
interaction: { |
||||
|
intersect: false, |
||||
|
mode: 'index' |
||||
|
}, |
||||
|
plugins: { |
||||
|
legend: { display: false }, |
||||
|
tooltip: { |
||||
|
backgroundColor: '#1a1a1a', |
||||
|
borderColor: '#333', |
||||
|
borderWidth: 1, |
||||
|
titleColor: '#999', |
||||
|
bodyColor: '#ff6600', |
||||
|
bodyFont: { family: 'monospace', size: 12 }, |
||||
|
titleFont: { family: 'monospace', size: 10 }, |
||||
|
padding: 8, |
||||
|
displayColors: false, |
||||
|
callbacks: { |
||||
|
label: (ctx) => { |
||||
|
const val = ctx.parsed.y; |
||||
|
return `$${this.decimalPipe.transform(val, '1.2-2') ?? val}`; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
scales: { |
||||
|
x: { |
||||
|
type: 'time', |
||||
|
grid: { display: false }, |
||||
|
ticks: { |
||||
|
color: '#555', |
||||
|
font: { family: 'monospace', size: 9 }, |
||||
|
maxTicksLimit: 6 |
||||
|
}, |
||||
|
border: { display: false } |
||||
|
}, |
||||
|
y: { |
||||
|
position: 'right', |
||||
|
grid: { color: 'rgba(255,255,255,0.03)' }, |
||||
|
ticks: { |
||||
|
color: '#555', |
||||
|
font: { family: 'monospace', size: 9 }, |
||||
|
callback: (value) => { |
||||
|
const num = Number(value); |
||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; |
||||
|
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`; |
||||
|
return String(num); |
||||
|
} |
||||
|
}, |
||||
|
border: { display: false } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,105 @@ |
|||||
|
import { |
||||
|
AfterViewInit, |
||||
|
Component, |
||||
|
ElementRef, |
||||
|
Input, |
||||
|
OnChanges, |
||||
|
OnDestroy, |
||||
|
SimpleChanges, |
||||
|
ViewChild |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
Chart, |
||||
|
LinearScale, |
||||
|
LineController, |
||||
|
LineElement, |
||||
|
PointElement |
||||
|
} from 'chart.js'; |
||||
|
|
||||
|
Chart.register(LinearScale, LineController, LineElement, PointElement); |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'gf-sparkline', |
||||
|
template: '<canvas #canvas class="sparkline-canvas"></canvas>', |
||||
|
styles: [ |
||||
|
` |
||||
|
:host { |
||||
|
display: inline-block; |
||||
|
height: 24px; |
||||
|
vertical-align: middle; |
||||
|
width: 80px; |
||||
|
} |
||||
|
.sparkline-canvas { |
||||
|
height: 100% !important; |
||||
|
width: 100% !important; |
||||
|
} |
||||
|
` |
||||
|
] |
||||
|
}) |
||||
|
export class GfSparklineComponent |
||||
|
implements AfterViewInit, OnChanges, OnDestroy |
||||
|
{ |
||||
|
@Input() data: number[] = []; |
||||
|
@Input() color = '#ff6600'; |
||||
|
|
||||
|
@ViewChild('canvas') canvas: ElementRef<HTMLCanvasElement>; |
||||
|
|
||||
|
private chart: Chart | null = null; |
||||
|
|
||||
|
public ngAfterViewInit(): void { |
||||
|
this.renderChart(); |
||||
|
} |
||||
|
|
||||
|
public ngOnChanges(changes: SimpleChanges): void { |
||||
|
if ( |
||||
|
(changes['data'] || changes['color']) && |
||||
|
!changes['data']?.firstChange |
||||
|
) { |
||||
|
this.renderChart(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy(): void { |
||||
|
this.chart?.destroy(); |
||||
|
} |
||||
|
|
||||
|
private renderChart(): void { |
||||
|
const el = this.canvas?.nativeElement; |
||||
|
if (!el || !this.data.length) return; |
||||
|
|
||||
|
this.chart?.destroy(); |
||||
|
|
||||
|
const ctx = el.getContext('2d'); |
||||
|
if (!ctx) return; |
||||
|
|
||||
|
this.chart = new Chart(ctx, { |
||||
|
type: 'line', |
||||
|
data: { |
||||
|
labels: this.data.map((_, i) => i), |
||||
|
datasets: [ |
||||
|
{ |
||||
|
data: this.data, |
||||
|
borderColor: this.color, |
||||
|
borderWidth: 1, |
||||
|
pointRadius: 0, |
||||
|
tension: 0.3, |
||||
|
fill: false |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
options: { |
||||
|
responsive: true, |
||||
|
maintainAspectRatio: false, |
||||
|
animation: false, |
||||
|
plugins: { |
||||
|
legend: { display: false }, |
||||
|
tooltip: { enabled: false } |
||||
|
}, |
||||
|
scales: { |
||||
|
x: { display: false }, |
||||
|
y: { display: false } |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
<div class="strategy-card"> |
||||
|
@if (step.options) { |
||||
|
<div class="strategy-question">{{ step.question }}</div> |
||||
|
<div class="strategy-options"> |
||||
|
@for (opt of step.options; track opt.value) { |
||||
|
<button |
||||
|
class="strategy-option" |
||||
|
[class.disabled]="disabled" |
||||
|
[disabled]="disabled" |
||||
|
(click)="selectOption(opt.value)" |
||||
|
> |
||||
|
<span class="option-label">{{ opt.label }}</span> |
||||
|
@if (opt.description) { |
||||
|
<span class="option-desc">{{ opt.description }}</span> |
||||
|
} |
||||
|
</button> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@if (step.recommendation) { |
||||
|
<div class="recommendation-card"> |
||||
|
<div class="rec-header">{{ step.recommendation.title }}</div> |
||||
|
<div class="rec-description">{{ step.recommendation.description }}</div> |
||||
|
<div class="allocation-bars"> |
||||
|
@for (alloc of step.recommendation.allocations; track alloc.asset) { |
||||
|
<div class="alloc-row"> |
||||
|
<span class="alloc-label">{{ alloc.asset }}</span> |
||||
|
<div class="alloc-bar-bg"> |
||||
|
<div |
||||
|
class="alloc-bar" |
||||
|
[style.background]="alloc.color" |
||||
|
[style.width.%]="alloc.percent" |
||||
|
></div> |
||||
|
</div> |
||||
|
<span class="alloc-pct">{{ alloc.percent }}%</span> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
<div class="rec-risk"> |
||||
|
Risk Level: {{ step.recommendation.riskLevel }} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
@ -0,0 +1,163 @@ |
|||||
|
.strategy-card { |
||||
|
padding: 0.25rem 0; |
||||
|
} |
||||
|
|
||||
|
.strategy-question { |
||||
|
color: #d9d9d9; |
||||
|
font-size: 0.85rem; |
||||
|
line-height: 1.5; |
||||
|
margin-bottom: 0.6rem; |
||||
|
} |
||||
|
|
||||
|
.strategy-options { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 0.4rem; |
||||
|
} |
||||
|
|
||||
|
.strategy-option { |
||||
|
align-items: flex-start; |
||||
|
background: #1a1a1a; |
||||
|
border: 1px solid #333; |
||||
|
border-radius: 3px; |
||||
|
color: #d9d9d9; |
||||
|
cursor: pointer; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
font-family: inherit; |
||||
|
gap: 0.15rem; |
||||
|
min-width: 140px; |
||||
|
padding: 0.5rem 0.75rem; |
||||
|
text-align: left; |
||||
|
transition: |
||||
|
border-color 0.15s, |
||||
|
background 0.15s; |
||||
|
|
||||
|
&:hover:not(.disabled) { |
||||
|
background: #222; |
||||
|
border-color: #ff6600; |
||||
|
} |
||||
|
|
||||
|
&:active:not(.disabled) { |
||||
|
background: #ff6600; |
||||
|
color: #000; |
||||
|
} |
||||
|
|
||||
|
&.disabled { |
||||
|
cursor: default; |
||||
|
opacity: 0.5; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.option-label { |
||||
|
font-size: 0.8rem; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.option-desc { |
||||
|
color: #888; |
||||
|
font-size: 0.68rem; |
||||
|
line-height: 1.3; |
||||
|
} |
||||
|
|
||||
|
// --- Recommendation card --- |
||||
|
|
||||
|
.recommendation-card { |
||||
|
background: #111; |
||||
|
border: 1px solid #333; |
||||
|
border-left: 3px solid #ff6600; |
||||
|
border-radius: 2px; |
||||
|
padding: 0.75rem 1rem; |
||||
|
} |
||||
|
|
||||
|
.rec-header { |
||||
|
color: #ff6600; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.85rem; |
||||
|
font-weight: 700; |
||||
|
letter-spacing: 0.5px; |
||||
|
margin-bottom: 0.4rem; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.rec-description { |
||||
|
color: #c8c8c8; |
||||
|
font-size: 0.78rem; |
||||
|
line-height: 1.5; |
||||
|
margin-bottom: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
.allocation-bars { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 0.35rem; |
||||
|
margin-bottom: 0.6rem; |
||||
|
} |
||||
|
|
||||
|
.alloc-row { |
||||
|
align-items: center; |
||||
|
display: flex; |
||||
|
gap: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.alloc-label { |
||||
|
color: #aaa; |
||||
|
flex-shrink: 0; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.68rem; |
||||
|
width: 160px; |
||||
|
} |
||||
|
|
||||
|
.alloc-bar-bg { |
||||
|
background: #222; |
||||
|
border-radius: 1px; |
||||
|
flex: 1; |
||||
|
height: 12px; |
||||
|
} |
||||
|
|
||||
|
.alloc-bar { |
||||
|
border-radius: 1px; |
||||
|
height: 100%; |
||||
|
transition: width 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.alloc-pct { |
||||
|
color: #d9d9d9; |
||||
|
flex-shrink: 0; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.7rem; |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
text-align: right; |
||||
|
width: 32px; |
||||
|
} |
||||
|
|
||||
|
.rec-risk { |
||||
|
color: #666; |
||||
|
font-family: |
||||
|
'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', |
||||
|
monospace; |
||||
|
font-size: 0.65rem; |
||||
|
letter-spacing: 0.5px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 768px) { |
||||
|
.strategy-options { |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
.strategy-option { |
||||
|
min-width: auto; |
||||
|
} |
||||
|
|
||||
|
.alloc-label { |
||||
|
font-size: 0.6rem; |
||||
|
width: 100px; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core'; |
||||
|
|
||||
|
import { StrategyStep } from '../../models/strategy-flow.types'; |
||||
|
|
||||
|
@Component({ |
||||
|
imports: [CommonModule], |
||||
|
selector: 'gf-strategy-card', |
||||
|
styleUrls: ['./strategy-card.component.scss'], |
||||
|
templateUrl: './strategy-card.component.html' |
||||
|
}) |
||||
|
export class GfStrategyCardComponent { |
||||
|
@Input() step: StrategyStep; |
||||
|
@Input() disabled = false; |
||||
|
@Output() optionSelected = new EventEmitter<string>(); |
||||
|
|
||||
|
public selectOption(value: string): void { |
||||
|
if (!this.disabled) { |
||||
|
this.optionSelected.emit(value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,261 @@ |
|||||
|
import { |
||||
|
StrategyRecommendation, |
||||
|
StrategyStep, |
||||
|
StrategyStepId |
||||
|
} from './strategy-flow.types'; |
||||
|
|
||||
|
const STEP_ORDER: StrategyStepId[] = [ |
||||
|
'goals', |
||||
|
'investmentAmount', |
||||
|
'timeHorizon', |
||||
|
'riskAppetite', |
||||
|
'experience' |
||||
|
]; |
||||
|
|
||||
|
const STEPS: Record<StrategyStepId, StrategyStep> = { |
||||
|
goals: { |
||||
|
stepId: 'goals', |
||||
|
question: "What's your primary investment goal?", |
||||
|
options: [ |
||||
|
{ |
||||
|
label: 'Retire Early', |
||||
|
value: 'retire_early', |
||||
|
description: 'Build enough wealth to stop working before 60' |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Grow Wealth', |
||||
|
value: 'grow_wealth', |
||||
|
description: 'Maximize long-term portfolio growth' |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Generate Income', |
||||
|
value: 'generate_income', |
||||
|
description: 'Create a steady stream of passive income' |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Preserve Capital', |
||||
|
value: 'preserve_capital', |
||||
|
description: 'Protect what you have from inflation and loss' |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
investmentAmount: { |
||||
|
stepId: 'investmentAmount', |
||||
|
question: 'How much do you have to invest?', |
||||
|
options: [ |
||||
|
{ |
||||
|
label: 'Under $1K', |
||||
|
value: 'under_1k', |
||||
|
description: 'Just getting started with a small amount' |
||||
|
}, |
||||
|
{ |
||||
|
label: '$1K - $10K', |
||||
|
value: '1k_10k', |
||||
|
description: 'Building a meaningful starter portfolio' |
||||
|
}, |
||||
|
{ |
||||
|
label: '$10K - $50K', |
||||
|
value: '10k_50k', |
||||
|
description: 'Enough for a diversified portfolio' |
||||
|
}, |
||||
|
{ |
||||
|
label: '$50K - $100K', |
||||
|
value: '50k_100k', |
||||
|
description: 'Substantial capital for broad allocation' |
||||
|
}, |
||||
|
{ |
||||
|
label: '$100K+', |
||||
|
value: '100k_plus', |
||||
|
description: 'Significant assets requiring careful management' |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
timeHorizon: { |
||||
|
stepId: 'timeHorizon', |
||||
|
question: "What's your investment time horizon?", |
||||
|
options: [ |
||||
|
{ |
||||
|
label: 'Less than 1 year', |
||||
|
value: 'lt_1y', |
||||
|
description: 'Short-term — need access to funds soon' |
||||
|
}, |
||||
|
{ |
||||
|
label: '1 - 5 years', |
||||
|
value: '1_5y', |
||||
|
description: 'Medium-term — saving for a goal in a few years' |
||||
|
}, |
||||
|
{ |
||||
|
label: '5 - 15 years', |
||||
|
value: '5_15y', |
||||
|
description: 'Long-term — growing wealth over time' |
||||
|
}, |
||||
|
{ |
||||
|
label: '15+ years', |
||||
|
value: '15y_plus', |
||||
|
description: 'Very long-term — retirement or generational wealth' |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
riskAppetite: { |
||||
|
stepId: 'riskAppetite', |
||||
|
question: 'How would you feel if your portfolio dropped 20% in a month?', |
||||
|
options: [ |
||||
|
{ |
||||
|
label: 'Panic sell', |
||||
|
value: 'panic_sell', |
||||
|
description: "I'd sell immediately to stop the bleeding" |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Worried but hold', |
||||
|
value: 'worried_hold', |
||||
|
description: "I'd be stressed but wouldn't sell" |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Stay the course', |
||||
|
value: 'stay_course', |
||||
|
description: "Downturns are normal, I'd do nothing" |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Buy the dip', |
||||
|
value: 'buy_dip', |
||||
|
description: "I'd see it as a buying opportunity" |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
experience: { |
||||
|
stepId: 'experience', |
||||
|
question: 'How experienced are you with investing?', |
||||
|
options: [ |
||||
|
{ |
||||
|
label: 'Beginner', |
||||
|
value: 'beginner', |
||||
|
description: 'New to investing, learning the basics' |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Some experience', |
||||
|
value: 'some_experience', |
||||
|
description: "I've made a few trades or own some funds" |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Intermediate', |
||||
|
value: 'intermediate', |
||||
|
description: 'Comfortable with stocks, ETFs, and diversification' |
||||
|
}, |
||||
|
{ |
||||
|
label: 'Expert', |
||||
|
value: 'expert', |
||||
|
description: |
||||
|
'Deep knowledge of markets, options, and advanced strategies' |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
recommendation: { |
||||
|
stepId: 'recommendation', |
||||
|
question: '' |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export function getStep(stepId: StrategyStepId): StrategyStep { |
||||
|
return STEPS[stepId]; |
||||
|
} |
||||
|
|
||||
|
export function getFirstStep(): StrategyStep { |
||||
|
return STEPS[STEP_ORDER[0]]; |
||||
|
} |
||||
|
|
||||
|
export function getNextStepId( |
||||
|
currentStepId: StrategyStepId |
||||
|
): StrategyStepId | null { |
||||
|
const idx = STEP_ORDER.indexOf(currentStepId); |
||||
|
if (idx < 0 || idx >= STEP_ORDER.length - 1) { |
||||
|
return 'recommendation'; |
||||
|
} |
||||
|
return STEP_ORDER[idx + 1]; |
||||
|
} |
||||
|
|
||||
|
export function computeRecommendation( |
||||
|
answers: Record<string, string> |
||||
|
): StrategyRecommendation { |
||||
|
let riskScore = 0; |
||||
|
|
||||
|
// Goals scoring
|
||||
|
const goalScores: Record<string, number> = { |
||||
|
retire_early: 2, |
||||
|
grow_wealth: 3, |
||||
|
generate_income: 1, |
||||
|
preserve_capital: 0 |
||||
|
}; |
||||
|
riskScore += goalScores[answers['goals']] ?? 1; |
||||
|
|
||||
|
// Time horizon scoring
|
||||
|
const horizonScores: Record<string, number> = { |
||||
|
lt_1y: 0, |
||||
|
'1_5y': 1, |
||||
|
'5_15y': 2, |
||||
|
'15y_plus': 3 |
||||
|
}; |
||||
|
riskScore += horizonScores[answers['timeHorizon']] ?? 1; |
||||
|
|
||||
|
// Risk appetite scoring
|
||||
|
const riskScores: Record<string, number> = { |
||||
|
panic_sell: 0, |
||||
|
worried_hold: 1, |
||||
|
stay_course: 2, |
||||
|
buy_dip: 3 |
||||
|
}; |
||||
|
riskScore += riskScores[answers['riskAppetite']] ?? 1; |
||||
|
|
||||
|
// Experience scoring
|
||||
|
const expScores: Record<string, number> = { |
||||
|
beginner: 0, |
||||
|
some_experience: 1, |
||||
|
intermediate: 2, |
||||
|
expert: 3 |
||||
|
}; |
||||
|
riskScore += expScores[answers['experience']] ?? 1; |
||||
|
|
||||
|
// Determine profile (max score = 12)
|
||||
|
if (riskScore <= 4) { |
||||
|
return { |
||||
|
title: 'Conservative: Dividend Income Portfolio', |
||||
|
description: |
||||
|
'Based on your preference for capital preservation and shorter time horizon, a conservative allocation focused on income-generating assets is recommended.', |
||||
|
allocations: [ |
||||
|
{ asset: 'Bonds (BND/AGG)', percent: 40, color: '#6b8cae' }, |
||||
|
{ asset: 'Dividend Stocks (VYM/SCHD)', percent: 25, color: '#33ff99' }, |
||||
|
{ asset: 'REITs (VNQ)', percent: 15, color: '#ff6600' }, |
||||
|
{ asset: 'Money Market/Cash', percent: 10, color: '#888' }, |
||||
|
{ asset: 'International Bonds (BNDX)', percent: 10, color: '#ffb84d' } |
||||
|
], |
||||
|
riskLevel: 'Conservative' |
||||
|
}; |
||||
|
} else if (riskScore <= 8) { |
||||
|
return { |
||||
|
title: 'Balanced: Core Satellite Strategy', |
||||
|
description: |
||||
|
'Your moderate risk tolerance and medium-to-long time horizon suit a balanced approach — broad index funds at the core with selective growth positions.', |
||||
|
allocations: [ |
||||
|
{ asset: 'US Total Market (VTI)', percent: 35, color: '#33ff99' }, |
||||
|
{ asset: 'International (VXUS)', percent: 20, color: '#6b8cae' }, |
||||
|
{ asset: 'Bonds (BND)', percent: 20, color: '#ffb84d' }, |
||||
|
{ asset: 'Growth ETFs (QQQ/VUG)', percent: 15, color: '#ff6600' }, |
||||
|
{ asset: 'REITs/Alternatives', percent: 10, color: '#888' } |
||||
|
], |
||||
|
riskLevel: 'Moderate' |
||||
|
}; |
||||
|
} else { |
||||
|
return { |
||||
|
title: 'Aggressive: Momentum Growth Portfolio', |
||||
|
description: |
||||
|
'Your high risk tolerance, long time horizon, and experience support an aggressive growth strategy focused on high-beta assets and sector concentration.', |
||||
|
allocations: [ |
||||
|
{ asset: 'Growth ETFs (QQQ/VUG)', percent: 30, color: '#ff6600' }, |
||||
|
{ asset: 'US Total Market (VTI)', percent: 25, color: '#33ff99' }, |
||||
|
{ asset: 'Sector ETFs (XLK/XLF)', percent: 20, color: '#ffb84d' }, |
||||
|
{ asset: 'International Growth', percent: 15, color: '#6b8cae' }, |
||||
|
{ asset: 'Small Cap (VB/IWM)', percent: 10, color: '#888' } |
||||
|
], |
||||
|
riskLevel: 'Aggressive' |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
export type StrategyStepId = |
||||
|
| 'goals' |
||||
|
| 'investmentAmount' |
||||
|
| 'timeHorizon' |
||||
|
| 'riskAppetite' |
||||
|
| 'experience' |
||||
|
| 'recommendation'; |
||||
|
|
||||
|
export interface StrategyOption { |
||||
|
label: string; |
||||
|
value: string; |
||||
|
description?: string; |
||||
|
} |
||||
|
|
||||
|
export interface StrategyAllocation { |
||||
|
asset: string; |
||||
|
percent: number; |
||||
|
color: string; |
||||
|
} |
||||
|
|
||||
|
export interface StrategyRecommendation { |
||||
|
title: string; |
||||
|
description: string; |
||||
|
allocations: StrategyAllocation[]; |
||||
|
riskLevel: string; |
||||
|
} |
||||
|
|
||||
|
export interface StrategyStep { |
||||
|
stepId: StrategyStepId; |
||||
|
question: string; |
||||
|
options?: StrategyOption[]; |
||||
|
recommendation?: StrategyRecommendation; |
||||
|
} |
||||
Loading…
Reference in new issue