fixed up dashboard
This commit is contained in:
parent
90168ba619
commit
e3bfd05b90
17 changed files with 1522 additions and 92 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
|
@ -9,6 +10,7 @@ export const appConfig: ApplicationConfig = {
|
|||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideAnimationsAsync()
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,12 +9,9 @@
|
|||
<mat-toolbar class="top-toolbar">
|
||||
<button mat-icon-button (click)="toggleSidenav()" class="mr-2">
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
<span class="text-lg font-semibold text-gray-800">{{ title }}</span>
|
||||
</button> <span class="text-lg font-semibold text-gray-800">{{ title }}</span>
|
||||
<span class="spacer"></span>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</button>
|
||||
<app-notifications></app-notifications>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
||||
import { NotificationsComponent } from './components/notifications/notifications';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
|
|
@ -18,7 +19,8 @@ import { SidebarComponent } from './components/sidebar/sidebar.component';
|
|||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
SidebarComponent
|
||||
SidebarComponent,
|
||||
NotificationsComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
::ng-deep .notification-menu {
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 12px 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 12px 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
white-space: normal !important;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background-color: #f0f9ff;
|
||||
border-left-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.notification-item.unread .font-medium {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="notificationMenu"
|
||||
[matBadge]="unreadCount"
|
||||
[matBadgeHidden]="unreadCount === 0"
|
||||
matBadgeColor="warn"
|
||||
matBadgeSize="small">
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #notificationMenu="matMenu" class="notification-menu">
|
||||
<div mat-menu-item disabled class="notification-header">
|
||||
<div class="flex items-center justify-between w-full px-2">
|
||||
<span class="font-semibold">Notifications</span>
|
||||
@if (notifications.length > 0) {
|
||||
<div class="flex gap-2">
|
||||
<button mat-button (click)="markAllAsRead()" class="text-xs">
|
||||
Mark all read
|
||||
</button>
|
||||
<button mat-button (click)="clearAll()" class="text-xs">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
@if (notifications.length === 0) {
|
||||
<div mat-menu-item disabled class="notification-empty">
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<mat-icon class="text-2xl">notifications_none</mat-icon>
|
||||
<p class="mt-1 text-sm">No notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
@for (notification of notifications.slice(0, 5); track notification.id) {
|
||||
<div
|
||||
mat-menu-item
|
||||
class="notification-item"
|
||||
[class.unread]="!notification.read"
|
||||
(click)="markAsRead(notification)">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<mat-icon [class]="getNotificationColor(notification.type)" class="mt-1">
|
||||
{{ getNotificationIcon(notification.type) }}
|
||||
</mat-icon>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium text-sm truncate">{{ notification.title }}</p>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="clearNotification(notification); $event.stopPropagation()"
|
||||
class="text-gray-400 hover:text-gray-600 ml-2">
|
||||
<mat-icon class="text-lg">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-600 text-xs mt-1 line-clamp-2">{{ notification.message }}</p>
|
||||
<p class="text-gray-400 text-xs mt-1">{{ formatTime(notification.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (!$last) {
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
}
|
||||
|
||||
@if (notifications.length > 5) {
|
||||
<mat-divider></mat-divider>
|
||||
<div mat-menu-item disabled class="text-center text-sm text-gray-500">
|
||||
{{ notifications.length - 5 }} more notifications...
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-menu>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Notifications } from './notifications';
|
||||
|
||||
describe('Notifications', () => {
|
||||
let component: Notifications;
|
||||
let fixture: ComponentFixture<Notifications>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Notifications]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Notifications);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { NotificationService, Notification } from '../../services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notifications',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatBadgeModule,
|
||||
MatMenuModule,
|
||||
MatListModule,
|
||||
MatDividerModule
|
||||
],
|
||||
templateUrl: './notifications.html',
|
||||
styleUrl: './notifications.css'
|
||||
})
|
||||
export class NotificationsComponent {
|
||||
private notificationService = inject(NotificationService);
|
||||
|
||||
get notifications() {
|
||||
return this.notificationService.notifications();
|
||||
}
|
||||
|
||||
get unreadCount() {
|
||||
return this.notificationService.unreadCount();
|
||||
}
|
||||
|
||||
markAsRead(notification: Notification) {
|
||||
this.notificationService.markAsRead(notification.id);
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
this.notificationService.markAllAsRead();
|
||||
}
|
||||
|
||||
clearNotification(notification: Notification) {
|
||||
this.notificationService.clearNotification(notification.id);
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.notificationService.clearAllNotifications();
|
||||
}
|
||||
|
||||
getNotificationIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'error': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'success': return 'check_circle';
|
||||
case 'info':
|
||||
default: return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
getNotificationColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'error': return 'text-red-600';
|
||||
case 'warning': return 'text-yellow-600';
|
||||
case 'success': return 'text-green-600';
|
||||
case 'info':
|
||||
default: return 'text-blue-600';
|
||||
}
|
||||
}
|
||||
|
||||
formatTime(timestamp: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - timestamp.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="space-y-6"> <!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Market Data</h1>
|
||||
<p class="text-gray-600 mt-1">Real-time market information and analytics</p>
|
||||
</div>
|
||||
<button mat-raised-button color="primary">
|
||||
<button mat-raised-button color="primary" (click)="refreshData()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -58,7 +60,21 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="ml-3 text-gray-600">Loading market data...</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="text-center py-8">
|
||||
<mat-icon class="text-red-500 text-4xl">error</mat-icon>
|
||||
<p class="text-red-600 mt-2">{{ error() }}</p>
|
||||
<button mat-button color="primary" (click)="refreshData()" class="mt-2">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="marketData()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
|
|
@ -111,12 +127,11 @@
|
|||
<td mat-cell *matCellDef="let stock" class="text-right text-gray-600">
|
||||
${{ stock.marketCap }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
</ng-container> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
|
||||
<!-- Market Analytics Tabs -->
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { Component, signal } from '@angular/core';
|
||||
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { WebSocketService } from '../../services/websocket.service';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
|
||||
export interface ExtendedMarketData {
|
||||
symbol: string;
|
||||
|
|
@ -26,13 +31,97 @@ export interface ExtendedMarketData {
|
|||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
MatTabsModule
|
||||
MatTabsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
templateUrl: './market-data.component.html',
|
||||
styleUrl: './market-data.component.css'
|
||||
})
|
||||
export class MarketDataComponent {
|
||||
protected marketData = signal<ExtendedMarketData[]>([
|
||||
export class MarketDataComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private webSocketService = inject(WebSocketService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
protected marketData = signal<ExtendedMarketData[]>([]);
|
||||
protected currentTime = signal<string>(new Date().toLocaleTimeString());
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected error = signal<string | null>(null);
|
||||
protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent', 'volume', 'marketCap'];
|
||||
ngOnInit() {
|
||||
// Update time every second
|
||||
const timeSubscription = interval(1000).subscribe(() => {
|
||||
this.currentTime.set(new Date().toLocaleTimeString());
|
||||
});
|
||||
this.subscriptions.push(timeSubscription);
|
||||
|
||||
// Load initial market data
|
||||
this.loadMarketData();
|
||||
|
||||
// Subscribe to real-time market data updates
|
||||
const wsSubscription = this.webSocketService.getMarketDataUpdates().subscribe({
|
||||
next: (update) => {
|
||||
this.updateMarketData(update);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('WebSocket market data error:', err);
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(wsSubscription);
|
||||
|
||||
// Fallback: Refresh market data every 30 seconds if WebSocket fails
|
||||
const dataSubscription = interval(30000).subscribe(() => {
|
||||
if (!this.webSocketService.isConnected()) {
|
||||
this.loadMarketData();
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(dataSubscription);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
private loadMarketData() {
|
||||
this.apiService.getMarketData().subscribe({
|
||||
next: (response) => {
|
||||
// Convert MarketData to ExtendedMarketData with mock extended properties
|
||||
const extendedData: ExtendedMarketData[] = response.data.map(item => ({
|
||||
...item,
|
||||
marketCap: this.getMockMarketCap(item.symbol),
|
||||
high52Week: item.price * 1.3, // Mock 52-week high (30% above current)
|
||||
low52Week: item.price * 0.7 // Mock 52-week low (30% below current)
|
||||
}));
|
||||
|
||||
this.marketData.set(extendedData);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load market data:', err);
|
||||
this.error.set('Failed to load market data');
|
||||
this.isLoading.set(false);
|
||||
this.snackBar.open('Failed to load market data', 'Dismiss', { duration: 5000 });
|
||||
|
||||
// Use mock data as fallback
|
||||
this.marketData.set(this.getMockData());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getMockMarketCap(symbol: string): string {
|
||||
const marketCaps: { [key: string]: string } = {
|
||||
'AAPL': '2.98T',
|
||||
'GOOGL': '1.78T',
|
||||
'MSFT': '3.08T',
|
||||
'TSLA': '789.2B',
|
||||
'AMZN': '1.59T'
|
||||
};
|
||||
return marketCaps[symbol] || '1.00T';
|
||||
}
|
||||
|
||||
private getMockData(): ExtendedMarketData[] {
|
||||
return [
|
||||
{
|
||||
symbol: 'AAPL',
|
||||
price: 192.53,
|
||||
|
|
@ -83,14 +172,27 @@ export class MarketDataComponent {
|
|||
high52Week: 170.17,
|
||||
low52Week: 118.35
|
||||
}
|
||||
]);
|
||||
];
|
||||
}
|
||||
refreshData() {
|
||||
this.isLoading.set(true);
|
||||
this.loadMarketData();
|
||||
}
|
||||
|
||||
protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent', 'volume', 'marketCap'];
|
||||
|
||||
protected currentTime = signal<string>(new Date().toLocaleTimeString()); constructor() {
|
||||
// Update time every second
|
||||
setInterval(() => {
|
||||
this.currentTime.set(new Date().toLocaleTimeString());
|
||||
}, 1000);
|
||||
private updateMarketData(update: any) {
|
||||
const currentData = this.marketData();
|
||||
const updatedData = currentData.map(item => {
|
||||
if (item.symbol === update.symbol) {
|
||||
return {
|
||||
...item,
|
||||
price: update.price,
|
||||
change: update.change,
|
||||
changePercent: update.changePercent,
|
||||
volume: update.volume
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.marketData.set(updatedData);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,203 @@
|
|||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Portfolio</h1>
|
||||
<p class="text-gray-600 mt-1">Manage and monitor your investment portfolio</p>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="refreshData()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">account_balance_wallet</mat-icon>
|
||||
<p class="mb-4">Portfolio management features will be implemented here</p>
|
||||
<!-- Portfolio Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Total Value</p>
|
||||
<p class="text-lg font-semibold text-gray-900">${{ portfolioSummary().totalValue.toLocaleString() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-blue-600 text-3xl">account_balance_wallet</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Total P&L</p>
|
||||
<p class="text-lg font-semibold" [class]="getPnLColor(portfolioSummary().totalPnL)">
|
||||
{{ portfolioSummary().totalPnL > 0 ? '+' : '' }}${{ portfolioSummary().totalPnL.toLocaleString() }}
|
||||
({{ portfolioSummary().totalPnLPercent.toFixed(2) }}%)
|
||||
</p>
|
||||
</div>
|
||||
<mat-icon class="text-green-600 text-3xl" *ngIf="portfolioSummary().totalPnL >= 0">trending_up</mat-icon>
|
||||
<mat-icon class="text-red-600 text-3xl" *ngIf="portfolioSummary().totalPnL < 0">trending_down</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Day Change</p>
|
||||
<p class="text-lg font-semibold" [class]="getPnLColor(portfolioSummary().dayChange)">
|
||||
{{ portfolioSummary().dayChange > 0 ? '+' : '' }}${{ portfolioSummary().dayChange.toLocaleString() }}
|
||||
({{ portfolioSummary().dayChangePercent.toFixed(2) }}%)
|
||||
</p>
|
||||
</div>
|
||||
<mat-icon class="text-purple-600 text-3xl">today</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Cash Available</p>
|
||||
<p class="text-lg font-semibold text-gray-900">${{ portfolioSummary().cash.toLocaleString() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-yellow-600 text-3xl">attach_money</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Portfolio Tabs -->
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Positions">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Current Positions</h3>
|
||||
<div class="flex gap-2">
|
||||
<button mat-button>
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Position
|
||||
</button>
|
||||
<button mat-button>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="ml-3 text-gray-600">Loading portfolio...</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="text-center py-8">
|
||||
<mat-icon class="text-red-500 text-4xl">error</mat-icon>
|
||||
<p class="text-red-600 mt-2">{{ error() }}</p>
|
||||
<button mat-button color="primary" (click)="refreshData()" class="mt-2">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
} @else if (positions().length === 0) {
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<mat-icon class="text-4xl">account_balance_wallet</mat-icon>
|
||||
<p class="mt-2">No positions found</p>
|
||||
<button mat-button color="primary" class="mt-2">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Your First Position
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="positions()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Symbol</th>
|
||||
<td mat-cell *matCellDef="let position" class="font-semibold text-gray-900">{{ position.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Quantity Column -->
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Quantity</th>
|
||||
<td mat-cell *matCellDef="let position" class="text-right text-gray-600">
|
||||
{{ position.quantity.toLocaleString() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Average Price Column -->
|
||||
<ng-container matColumnDef="avgPrice">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Avg Price</th>
|
||||
<td mat-cell *matCellDef="let position" class="text-right text-gray-600">
|
||||
${{ position.avgPrice.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Current Price Column -->
|
||||
<ng-container matColumnDef="currentPrice">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Current Price</th>
|
||||
<td mat-cell *matCellDef="let position" class="text-right font-medium text-gray-900">
|
||||
${{ position.currentPrice.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Market Value Column -->
|
||||
<ng-container matColumnDef="marketValue">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Market Value</th>
|
||||
<td mat-cell *matCellDef="let position" class="text-right font-medium text-gray-900">
|
||||
${{ position.marketValue.toLocaleString() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Unrealized P&L Column -->
|
||||
<ng-container matColumnDef="unrealizedPnL">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Unrealized P&L</th>
|
||||
<td mat-cell *matCellDef="let position"
|
||||
class="text-right font-medium"
|
||||
[class]="getPnLColor(position.unrealizedPnL)">
|
||||
{{ position.unrealizedPnL > 0 ? '+' : '' }}${{ position.unrealizedPnL.toLocaleString() }}
|
||||
({{ position.unrealizedPnLPercent.toFixed(2) }}%)
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Day Change Column -->
|
||||
<ng-container matColumnDef="dayChange">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Day Change</th>
|
||||
<td mat-cell *matCellDef="let position"
|
||||
class="text-right font-medium"
|
||||
[class]="getPnLColor(position.dayChange)">
|
||||
{{ position.dayChange > 0 ? '+' : '' }}${{ position.dayChange.toFixed(2) }}
|
||||
({{ position.dayChangePercent.toFixed(2) }}%)
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Performance">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">trending_up</mat-icon>
|
||||
<p class="mb-4">Performance charts and analytics will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Orders">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">receipt</mat-icon>
|
||||
<p class="mb-4">Order history and management will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,159 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
|
||||
export interface Position {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
avgPrice: number;
|
||||
currentPrice: number;
|
||||
marketValue: number;
|
||||
unrealizedPnL: number;
|
||||
unrealizedPnLPercent: number;
|
||||
dayChange: number;
|
||||
dayChangePercent: number;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
totalValue: number;
|
||||
totalCost: number;
|
||||
totalPnL: number;
|
||||
totalPnLPercent: number;
|
||||
dayChange: number;
|
||||
dayChangePercent: number;
|
||||
cash: number;
|
||||
positionsCount: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portfolio',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatIconModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatTabsModule
|
||||
],
|
||||
templateUrl: './portfolio.component.html',
|
||||
styleUrl: './portfolio.component.css'
|
||||
})
|
||||
export class PortfolioComponent {}
|
||||
export class PortfolioComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
protected portfolioSummary = signal<PortfolioSummary>({
|
||||
totalValue: 0,
|
||||
totalCost: 0,
|
||||
totalPnL: 0,
|
||||
totalPnLPercent: 0,
|
||||
dayChange: 0,
|
||||
dayChangePercent: 0,
|
||||
cash: 0,
|
||||
positionsCount: 0
|
||||
});
|
||||
|
||||
protected positions = signal<Position[]>([]);
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected error = signal<string | null>(null);
|
||||
protected displayedColumns = ['symbol', 'quantity', 'avgPrice', 'currentPrice', 'marketValue', 'unrealizedPnL', 'dayChange'];
|
||||
|
||||
ngOnInit() {
|
||||
this.loadPortfolioData();
|
||||
|
||||
// Refresh portfolio data every 30 seconds
|
||||
const portfolioSubscription = interval(30000).subscribe(() => {
|
||||
this.loadPortfolioData();
|
||||
});
|
||||
this.subscriptions.push(portfolioSubscription);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
private loadPortfolioData() {
|
||||
// Since we don't have a portfolio endpoint yet, let's create mock data
|
||||
// In a real implementation, this would call this.apiService.getPortfolio()
|
||||
|
||||
setTimeout(() => {
|
||||
const mockPositions: Position[] = [
|
||||
{
|
||||
symbol: 'AAPL',
|
||||
quantity: 100,
|
||||
avgPrice: 180.50,
|
||||
currentPrice: 192.53,
|
||||
marketValue: 19253,
|
||||
unrealizedPnL: 1203,
|
||||
unrealizedPnLPercent: 6.67,
|
||||
dayChange: 241,
|
||||
dayChangePercent: 1.27
|
||||
},
|
||||
{
|
||||
symbol: 'MSFT',
|
||||
quantity: 50,
|
||||
avgPrice: 400.00,
|
||||
currentPrice: 415.26,
|
||||
marketValue: 20763,
|
||||
unrealizedPnL: 763,
|
||||
unrealizedPnLPercent: 3.82,
|
||||
dayChange: 436.50,
|
||||
dayChangePercent: 2.15
|
||||
},
|
||||
{
|
||||
symbol: 'GOOGL',
|
||||
quantity: 10,
|
||||
avgPrice: 2900.00,
|
||||
currentPrice: 2847.56,
|
||||
marketValue: 28475.60,
|
||||
unrealizedPnL: -524.40,
|
||||
unrealizedPnLPercent: -1.81,
|
||||
dayChange: -123.40,
|
||||
dayChangePercent: -0.43
|
||||
}
|
||||
];
|
||||
|
||||
const summary: PortfolioSummary = {
|
||||
totalValue: mockPositions.reduce((sum, pos) => sum + pos.marketValue, 0) + 25000, // + cash
|
||||
totalCost: mockPositions.reduce((sum, pos) => sum + (pos.avgPrice * pos.quantity), 0),
|
||||
totalPnL: mockPositions.reduce((sum, pos) => sum + pos.unrealizedPnL, 0),
|
||||
totalPnLPercent: 0,
|
||||
dayChange: mockPositions.reduce((sum, pos) => sum + pos.dayChange, 0),
|
||||
dayChangePercent: 0,
|
||||
cash: 25000,
|
||||
positionsCount: mockPositions.length
|
||||
};
|
||||
|
||||
summary.totalPnLPercent = (summary.totalPnL / summary.totalCost) * 100;
|
||||
summary.dayChangePercent = (summary.dayChange / (summary.totalValue - summary.dayChange)) * 100;
|
||||
|
||||
this.positions.set(mockPositions);
|
||||
this.portfolioSummary.set(summary);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
refreshData() {
|
||||
this.isLoading.set(true);
|
||||
this.loadPortfolioData();
|
||||
}
|
||||
|
||||
getPnLColor(value: number): string {
|
||||
if (value > 0) return 'text-green-600';
|
||||
if (value < 0) return 'text-red-600';
|
||||
return 'text-gray-600';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,178 @@
|
|||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Risk Management</h1>
|
||||
<p class="text-gray-600 mt-1">Monitor and control trading risks and exposure</p>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="refreshData()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">security</mat-icon>
|
||||
<p class="mb-4">Risk management tools and monitoring will be implemented here</p>
|
||||
<!-- Risk Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
@if (riskThresholds(); as thresholds) {
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Max Position Size</p>
|
||||
<p class="text-lg font-semibold text-gray-900">${{ thresholds.maxPositionSize.toLocaleString() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-blue-600 text-3xl">account_balance</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Max Daily Loss</p>
|
||||
<p class="text-lg font-semibold text-red-600">${{ thresholds.maxDailyLoss.toLocaleString() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-red-600 text-3xl">trending_down</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Portfolio Risk Limit</p>
|
||||
<p class="text-lg font-semibold text-yellow-600">{{ (thresholds.maxPortfolioRisk * 100).toFixed(1) }}%</p>
|
||||
</div>
|
||||
<mat-icon class="text-yellow-600 text-3xl">pie_chart</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Volatility Limit</p>
|
||||
<p class="text-lg font-semibold text-purple-600">{{ (thresholds.volatilityLimit * 100).toFixed(1) }}%</p>
|
||||
</div>
|
||||
<mat-icon class="text-purple-600 text-3xl">show_chart</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Risk Thresholds Configuration -->
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Risk Thresholds Configuration</h3>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="ml-3 text-gray-600">Loading risk settings...</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="text-center py-8">
|
||||
<mat-icon class="text-red-500 text-4xl">error</mat-icon>
|
||||
<p class="text-red-600 mt-2">{{ error() }}</p>
|
||||
<button mat-button color="primary" (click)="refreshData()" class="mt-2">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="thresholdsForm" (ngSubmit)="saveThresholds()">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Max Position Size ($)</mat-label>
|
||||
<input matInput type="number" formControlName="maxPositionSize" placeholder="100000">
|
||||
<mat-icon matSuffix>attach_money</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Max Daily Loss ($)</mat-label>
|
||||
<input matInput type="number" formControlName="maxDailyLoss" placeholder="5000">
|
||||
<mat-icon matSuffix>trending_down</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Max Portfolio Risk (0-1)</mat-label>
|
||||
<input matInput type="number" step="0.01" formControlName="maxPortfolioRisk" placeholder="0.1">
|
||||
<mat-icon matSuffix>pie_chart</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Volatility Limit (0-1)</mat-label>
|
||||
<input matInput type="number" step="0.01" formControlName="volatilityLimit" placeholder="0.3">
|
||||
<mat-icon matSuffix>show_chart</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="thresholdsForm.invalid || isSaving()">
|
||||
@if (isSaving()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Save Thresholds
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</mat-card>
|
||||
|
||||
<!-- Risk History Table -->
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Recent Risk Evaluations</h3>
|
||||
</div>
|
||||
|
||||
@if (riskHistory().length === 0) {
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<mat-icon class="text-4xl">history</mat-icon>
|
||||
<p class="mt-2">No risk evaluations found</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="riskHistory()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Symbol</th>
|
||||
<td mat-cell *matCellDef="let risk" class="font-semibold text-gray-900">{{ risk.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Position Value Column -->
|
||||
<ng-container matColumnDef="positionValue">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Position Value</th>
|
||||
<td mat-cell *matCellDef="let risk" class="text-right font-medium text-gray-900">
|
||||
${{ risk.positionValue.toLocaleString() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Risk Level Column -->
|
||||
<ng-container matColumnDef="riskLevel">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-center font-medium text-gray-900">Risk Level</th>
|
||||
<td mat-cell *matCellDef="let risk" class="text-center">
|
||||
<span class="px-2 py-1 rounded-full text-sm font-medium" [class]="getRiskLevelColor(risk.riskLevel)">
|
||||
{{ risk.riskLevel }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Violations Column -->
|
||||
<ng-container matColumnDef="violations">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Violations</th>
|
||||
<td mat-cell *matCellDef="let risk" class="text-gray-600">
|
||||
@if (risk.violations.length > 0) {
|
||||
<span class="text-red-600">{{ risk.violations.join(', ') }}</span>
|
||||
} @else {
|
||||
<span class="text-green-600">None</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,135 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ApiService, RiskThresholds, RiskEvaluation } from '../../services/api.service';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-risk-management',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatIconModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSnackBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
templateUrl: './risk-management.component.html',
|
||||
styleUrl: './risk-management.component.css'
|
||||
})
|
||||
export class RiskManagementComponent {}
|
||||
export class RiskManagementComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private fb = inject(FormBuilder);
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
protected riskThresholds = signal<RiskThresholds | null>(null);
|
||||
protected riskHistory = signal<RiskEvaluation[]>([]);
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected isSaving = signal<boolean>(false);
|
||||
protected error = signal<string | null>(null);
|
||||
|
||||
protected thresholdsForm: FormGroup;
|
||||
protected displayedColumns = ['symbol', 'positionValue', 'riskLevel', 'violations', 'timestamp'];
|
||||
|
||||
constructor() {
|
||||
this.thresholdsForm = this.fb.group({
|
||||
maxPositionSize: [0, [Validators.required, Validators.min(0)]],
|
||||
maxDailyLoss: [0, [Validators.required, Validators.min(0)]],
|
||||
maxPortfolioRisk: [0, [Validators.required, Validators.min(0), Validators.max(1)]],
|
||||
volatilityLimit: [0, [Validators.required, Validators.min(0), Validators.max(1)]]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadRiskThresholds();
|
||||
this.loadRiskHistory();
|
||||
|
||||
// Refresh risk history every 30 seconds
|
||||
const historySubscription = interval(30000).subscribe(() => {
|
||||
this.loadRiskHistory();
|
||||
});
|
||||
this.subscriptions.push(historySubscription);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
private loadRiskThresholds() {
|
||||
this.apiService.getRiskThresholds().subscribe({
|
||||
next: (response) => {
|
||||
this.riskThresholds.set(response.data);
|
||||
this.thresholdsForm.patchValue(response.data);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load risk thresholds:', err);
|
||||
this.error.set('Failed to load risk thresholds');
|
||||
this.isLoading.set(false);
|
||||
this.snackBar.open('Failed to load risk thresholds', 'Dismiss', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadRiskHistory() {
|
||||
this.apiService.getRiskHistory().subscribe({
|
||||
next: (response) => {
|
||||
this.riskHistory.set(response.data);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load risk history:', err);
|
||||
this.snackBar.open('Failed to load risk history', 'Dismiss', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveThresholds() {
|
||||
if (this.thresholdsForm.valid) {
|
||||
this.isSaving.set(true);
|
||||
const thresholds = this.thresholdsForm.value as RiskThresholds;
|
||||
|
||||
this.apiService.updateRiskThresholds(thresholds).subscribe({
|
||||
next: (response) => {
|
||||
this.riskThresholds.set(response.data);
|
||||
this.isSaving.set(false);
|
||||
this.snackBar.open('Risk thresholds updated successfully', 'Dismiss', { duration: 3000 });
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to save risk thresholds:', err);
|
||||
this.isSaving.set(false);
|
||||
this.snackBar.open('Failed to save risk thresholds', 'Dismiss', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshData() {
|
||||
this.isLoading.set(true);
|
||||
this.loadRiskThresholds();
|
||||
this.loadRiskHistory();
|
||||
}
|
||||
|
||||
getRiskLevelColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'LOW': return 'text-green-600';
|
||||
case 'MEDIUM': return 'text-yellow-600';
|
||||
case 'HIGH': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface RiskThresholds {
|
||||
maxPositionSize: number;
|
||||
maxDailyLoss: number;
|
||||
maxPortfolioRisk: number;
|
||||
volatilityLimit: number;
|
||||
}
|
||||
|
||||
export interface RiskEvaluation {
|
||||
symbol: string;
|
||||
positionValue: number;
|
||||
positionRisk: number;
|
||||
violations: string[];
|
||||
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
}
|
||||
|
||||
export interface MarketData {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly baseUrls = {
|
||||
riskGuardian: 'http://localhost:3002',
|
||||
strategyOrchestrator: 'http://localhost:3003',
|
||||
marketDataGateway: 'http://localhost:3001'
|
||||
};
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Risk Guardian API
|
||||
getRiskThresholds(): Observable<{ success: boolean; data: RiskThresholds }> {
|
||||
return this.http.get<{ success: boolean; data: RiskThresholds }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/thresholds`
|
||||
);
|
||||
}
|
||||
|
||||
updateRiskThresholds(thresholds: RiskThresholds): Observable<{ success: boolean; data: RiskThresholds }> {
|
||||
return this.http.put<{ success: boolean; data: RiskThresholds }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/thresholds`,
|
||||
thresholds
|
||||
);
|
||||
}
|
||||
|
||||
evaluateRisk(params: {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
portfolioValue: number;
|
||||
}): Observable<{ success: boolean; data: RiskEvaluation }> {
|
||||
return this.http.post<{ success: boolean; data: RiskEvaluation }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/evaluate`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
getRiskHistory(): Observable<{ success: boolean; data: RiskEvaluation[] }> {
|
||||
return this.http.get<{ success: boolean; data: RiskEvaluation[] }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/history`
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy Orchestrator API
|
||||
getStrategies(): Observable<{ success: boolean; data: any[] }> {
|
||||
return this.http.get<{ success: boolean; data: any[] }>(
|
||||
`${this.baseUrls.strategyOrchestrator}/api/strategies`
|
||||
);
|
||||
}
|
||||
|
||||
createStrategy(strategy: any): Observable<{ success: boolean; data: any }> {
|
||||
return this.http.post<{ success: boolean; data: any }>(
|
||||
`${this.baseUrls.strategyOrchestrator}/api/strategies`,
|
||||
strategy
|
||||
);
|
||||
}
|
||||
// Market Data Gateway API
|
||||
getMarketData(symbols: string[] = ['AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN']): Observable<{ success: boolean; data: MarketData[] }> {
|
||||
const symbolsParam = symbols.join(',');
|
||||
return this.http.get<{ success: boolean; data: MarketData[] }>(
|
||||
`${this.baseUrls.marketDataGateway}/api/market-data?symbols=${symbolsParam}`
|
||||
);
|
||||
}
|
||||
|
||||
// Health checks
|
||||
checkServiceHealth(service: 'riskGuardian' | 'strategyOrchestrator' | 'marketDataGateway'): Observable<any> {
|
||||
return this.http.get(`${this.baseUrls[service]}/health`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { WebSocketService, RiskAlert } from './websocket.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: 'info' | 'warning' | 'error' | 'success';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class NotificationService {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private webSocketService = inject(WebSocketService);
|
||||
private riskAlertsSubscription?: Subscription;
|
||||
|
||||
// Reactive state
|
||||
public notifications = signal<Notification[]>([]);
|
||||
public unreadCount = signal<number>(0);
|
||||
|
||||
constructor() {
|
||||
this.initializeRiskAlerts();
|
||||
}
|
||||
|
||||
private initializeRiskAlerts() {
|
||||
// Subscribe to risk alerts from WebSocket
|
||||
this.riskAlertsSubscription = this.webSocketService.getRiskAlerts().subscribe({
|
||||
next: (alert: RiskAlert) => {
|
||||
this.handleRiskAlert(alert);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Risk alert subscription error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleRiskAlert(alert: RiskAlert) {
|
||||
const notification: Notification = {
|
||||
id: alert.id,
|
||||
type: this.mapSeverityToType(alert.severity),
|
||||
title: `Risk Alert: ${alert.symbol}`,
|
||||
message: alert.message,
|
||||
timestamp: new Date(alert.timestamp),
|
||||
read: false
|
||||
};
|
||||
|
||||
this.addNotification(notification);
|
||||
this.showSnackBarAlert(notification);
|
||||
}
|
||||
|
||||
private mapSeverityToType(severity: string): 'info' | 'warning' | 'error' | 'success' {
|
||||
switch (severity) {
|
||||
case 'HIGH': return 'error';
|
||||
case 'MEDIUM': return 'warning';
|
||||
case 'LOW': return 'info';
|
||||
default: return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
private showSnackBarAlert(notification: Notification) {
|
||||
const actionText = notification.type === 'error' ? 'Review' : 'Dismiss';
|
||||
const duration = notification.type === 'error' ? 10000 : 5000;
|
||||
|
||||
this.snackBar.open(
|
||||
`${notification.title}: ${notification.message}`,
|
||||
actionText,
|
||||
{
|
||||
duration,
|
||||
panelClass: [`snack-${notification.type}`]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Public methods
|
||||
addNotification(notification: Notification) {
|
||||
const current = this.notifications();
|
||||
const updated = [notification, ...current].slice(0, 50); // Keep only latest 50
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
markAsRead(notificationId: string) {
|
||||
const current = this.notifications();
|
||||
const updated = current.map(n =>
|
||||
n.id === notificationId ? { ...n, read: true } : n
|
||||
);
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
const current = this.notifications();
|
||||
const updated = current.map(n => ({ ...n, read: true }));
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
clearNotification(notificationId: string) {
|
||||
const current = this.notifications();
|
||||
const updated = current.filter(n => n.id !== notificationId);
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
clearAllNotifications() {
|
||||
this.notifications.set([]);
|
||||
this.unreadCount.set(0);
|
||||
}
|
||||
|
||||
private updateUnreadCount() {
|
||||
const unread = this.notifications().filter(n => !n.read).length;
|
||||
this.unreadCount.set(unread);
|
||||
}
|
||||
|
||||
// Manual notification methods
|
||||
showSuccess(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'success',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 3000,
|
||||
panelClass: ['snack-success']
|
||||
});
|
||||
}
|
||||
|
||||
showError(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 8000,
|
||||
panelClass: ['snack-error']
|
||||
});
|
||||
}
|
||||
|
||||
showWarning(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 5000,
|
||||
panelClass: ['snack-warning']
|
||||
});
|
||||
}
|
||||
|
||||
showInfo(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'info',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 4000,
|
||||
panelClass: ['snack-info']
|
||||
});
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.riskAlertsSubscription?.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MarketDataUpdate {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface RiskAlert {
|
||||
id: string;
|
||||
symbol: string;
|
||||
alertType: 'POSITION_LIMIT' | 'DAILY_LOSS' | 'VOLATILITY' | 'PORTFOLIO_RISK';
|
||||
message: string;
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class WebSocketService {
|
||||
private readonly WS_ENDPOINTS = {
|
||||
marketData: 'ws://localhost:3001/ws',
|
||||
riskGuardian: 'ws://localhost:3002/ws',
|
||||
strategyOrchestrator: 'ws://localhost:3003/ws'
|
||||
};
|
||||
|
||||
private connections = new Map<string, WebSocket>();
|
||||
private messageSubjects = new Map<string, Subject<WebSocketMessage>>();
|
||||
|
||||
// Connection status signals
|
||||
public isConnected = signal<boolean>(false);
|
||||
public connectionStatus = signal<{ [key: string]: boolean }>({
|
||||
marketData: false,
|
||||
riskGuardian: false,
|
||||
strategyOrchestrator: false
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.initializeConnections();
|
||||
}
|
||||
|
||||
private initializeConnections() {
|
||||
// Initialize WebSocket connections for all services
|
||||
Object.entries(this.WS_ENDPOINTS).forEach(([service, url]) => {
|
||||
this.connect(service, url);
|
||||
});
|
||||
}
|
||||
|
||||
private connect(serviceName: string, url: string) {
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
const messageSubject = new Subject<WebSocketMessage>();
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`Connected to ${serviceName} WebSocket`);
|
||||
this.updateConnectionStatus(serviceName, true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
messageSubject.next(message);
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse WebSocket message from ${serviceName}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log(`Disconnected from ${serviceName} WebSocket`);
|
||||
this.updateConnectionStatus(serviceName, false);
|
||||
|
||||
// Attempt to reconnect after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.connect(serviceName, url);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`WebSocket error for ${serviceName}:`, error);
|
||||
this.updateConnectionStatus(serviceName, false);
|
||||
};
|
||||
|
||||
this.connections.set(serviceName, ws);
|
||||
this.messageSubjects.set(serviceName, messageSubject);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to ${serviceName} WebSocket:`, error);
|
||||
this.updateConnectionStatus(serviceName, false);
|
||||
}
|
||||
}
|
||||
|
||||
private updateConnectionStatus(serviceName: string, isConnected: boolean) {
|
||||
const currentStatus = this.connectionStatus();
|
||||
const newStatus = { ...currentStatus, [serviceName]: isConnected };
|
||||
this.connectionStatus.set(newStatus);
|
||||
|
||||
// Update overall connection status
|
||||
const overallConnected = Object.values(newStatus).some(status => status);
|
||||
this.isConnected.set(overallConnected);
|
||||
}
|
||||
|
||||
// Market Data Updates
|
||||
getMarketDataUpdates(): Observable<MarketDataUpdate> {
|
||||
const subject = this.messageSubjects.get('marketData');
|
||||
if (!subject) {
|
||||
throw new Error('Market data WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message => message.type === 'market_data_update'),
|
||||
map(message => message.data as MarketDataUpdate)
|
||||
);
|
||||
}
|
||||
|
||||
// Risk Alerts
|
||||
getRiskAlerts(): Observable<RiskAlert> {
|
||||
const subject = this.messageSubjects.get('riskGuardian');
|
||||
if (!subject) {
|
||||
throw new Error('Risk Guardian WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message => message.type === 'risk_alert'),
|
||||
map(message => message.data as RiskAlert)
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy Updates
|
||||
getStrategyUpdates(): Observable<any> {
|
||||
const subject = this.messageSubjects.get('strategyOrchestrator');
|
||||
if (!subject) {
|
||||
throw new Error('Strategy Orchestrator WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message => message.type === 'strategy_update'),
|
||||
map(message => message.data)
|
||||
);
|
||||
}
|
||||
|
||||
// Send messages
|
||||
sendMessage(serviceName: string, message: any) {
|
||||
const ws = this.connections.get(serviceName);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn(`Cannot send message to ${serviceName}: WebSocket not connected`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
disconnect() {
|
||||
this.connections.forEach((ws, serviceName) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
this.connections.clear();
|
||||
this.messageSubjects.clear();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue