commit ce711aa6d4675d433c190b79137560045681e9e1 Author: rckrdmrd Date: Fri Jan 16 08:33:11 2026 -0600 Migración desde trading-platform/apps/mcp-investment - Estándar multi-repo v2 Co-Authored-By: Claude Opus 4.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bd3fb2d --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# MCP INVESTMENT SERVER CONFIGURATION + +PORT=3093 +NODE_ENV=development +LOG_LEVEL=info + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=trading_platform +DB_USER=trading_app +DB_PASSWORD=your_password +DB_SSL=false +DB_POOL_MAX=20 + +# Wallet Service +WALLET_SERVICE_URL=http://localhost:3090 +WALLET_SERVICE_TIMEOUT=10000 + +# Investment Config +MIN_ALLOCATION_AMOUNT=100 +MAX_ALLOCATION_AMOUNT=100000 +MIN_WITHDRAWAL_AMOUNT=10 +PROFIT_DISTRIBUTION_FREQUENCY_DAYS=30 +ALLOCATION_LOCK_PERIOD_DAYS=7 +EARLY_WITHDRAWAL_PENALTY_PERCENT=5 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..025cb38 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build && npm prune --production + +FROM node:20-alpine +WORKDIR /app +RUN addgroup -g 1001 -S nodejs && adduser -S investment -u 1001 +COPY --from=builder --chown=investment:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=investment:nodejs /app/dist ./dist +COPY --from=builder --chown=investment:nodejs /app/package.json ./ +USER investment +EXPOSE 3093 +HEALTHCHECK --interval=30s --timeout=10s CMD wget -q --spider http://localhost:3093/health || exit 1 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..477d229 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# MCP Investment Server + +Investment/Agent Allocation MCP Server for the Trading Platform. Manages Money Manager agents (Atlas, Orion, Nova) allocations and profit distributions. + +## Agents + +| Agent | Risk Level | Target Return | Max Drawdown | Mgmt Fee | Perf Fee | +|-------|------------|---------------|--------------|----------|----------| +| **Atlas** | Conservative | 8%/yr | 5% | 1.5% | 15% | +| **Orion** | Moderate | 15%/yr | 12% | 2.0% | 20% | +| **Nova** | Aggressive | 25%/yr | 25% | 2.5% | 25% | + +## Quick Start + +```bash +npm install +cp .env.example .env +npm run dev +``` + +## API Endpoints + +### Agents +- `GET /api/v1/agents` - List all agents +- `GET /api/v1/agents/:agentType` - Get agent config +- `GET /api/v1/agents/:agentType/performance` - Agent performance metrics +- `GET /api/v1/agents/:agentType/distributions` - Profit distribution history + +### Allocations +- `POST /api/v1/allocations` - Create allocation (debits wallet) +- `GET /api/v1/allocations` - List allocations +- `GET /api/v1/allocations/:id` - Get allocation +- `POST /api/v1/allocations/:id/fund` - Add funds +- `POST /api/v1/allocations/:id/withdraw` - Withdraw funds +- `PATCH /api/v1/allocations/:id/status` - Update status +- `GET /api/v1/allocations/:id/transactions` - Transaction history + +### User +- `GET /api/v1/users/:userId/allocations` - User allocations +- `GET /api/v1/users/:userId/investment-summary` - Portfolio summary + +### Admin +- `POST /api/v1/admin/distribute-profits` - Distribute profits + +## MCP Tools (14) + +| Tool | Description | +|------|-------------| +| `investment_list_agents` | List agents | +| `investment_get_agent` | Get agent config | +| `investment_create_allocation` | Create allocation | +| `investment_get_allocation` | Get allocation | +| `investment_list_allocations` | List allocations | +| `investment_get_user_allocations` | User allocations | +| `investment_fund_allocation` | Add funds | +| `investment_withdraw` | Withdraw funds | +| `investment_update_status` | Update status | +| `investment_get_transactions` | Get transactions | +| `investment_distribute_profits` | Distribute profits | +| `investment_get_distributions` | Distribution history | +| `investment_get_user_summary` | User summary | +| `investment_get_agent_performance` | Agent metrics | + +## Allocation Status + +- `pending` - Awaiting activation +- `active` - Active and trading +- `paused` - Temporarily paused +- `liquidating` - Liquidation in progress +- `closed` - Closed/fully withdrawn + +## Features + +- 7-day lock period for new allocations +- 5% early withdrawal penalty +- Automatic profit distribution to allocations +- Management and performance fee deduction +- Integration with Wallet service for funding/withdrawals + +## License + +UNLICENSED - Private diff --git a/package.json b/package.json new file mode 100644 index 0000000..e94460a --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "@trading-platform/mcp-investment", + "version": "1.0.0", + "description": "MCP Server for Investment/PAMM - Money Manager Agent Allocations", + "main": "dist/index.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.18.2", + "pg": "^8.11.3", + "zod": "^3.22.4", + "winston": "^3.11.0", + "decimal.js": "^10.4.3", + "uuid": "^9.0.1", + "dotenv": "^16.3.1", + "helmet": "^7.1.0", + "cors": "^2.8.5", + "jsonwebtoken": "^9.0.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", + "@types/cors": "^2.8.17", + "@types/jsonwebtoken": "^9.0.5", + "typescript": "^5.3.2", + "ts-node-dev": "^2.0.0" + }, + "engines": { "node": ">=18.0.0" }, + "private": true +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..cc9e3bf --- /dev/null +++ b/src/config.ts @@ -0,0 +1,103 @@ +/** + * MCP Investment Server Configuration + * Agent allocations and profit distribution + */ + +import { Pool, PoolConfig } from 'pg'; + +// Database configuration +const dbConfig: PoolConfig = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + database: process.env.DB_NAME || 'trading_platform', + user: process.env.DB_USER || 'trading_app', + password: process.env.DB_PASSWORD || '', + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + max: parseInt(process.env.DB_POOL_MAX || '20', 10), + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}; + +// Database pool singleton +let pool: Pool | null = null; + +export function getPool(): Pool { + if (!pool) { + pool = new Pool(dbConfig); + pool.on('error', (err) => { + console.error('Unexpected database pool error:', err); + }); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} + +// Server configuration +export const serverConfig = { + port: parseInt(process.env.PORT || '3093', 10), + env: process.env.NODE_ENV || 'development', + logLevel: process.env.LOG_LEVEL || 'info', +}; + +// Wallet service configuration +export const walletServiceConfig = { + baseUrl: process.env.WALLET_SERVICE_URL || 'http://localhost:3090', + timeout: parseInt(process.env.WALLET_SERVICE_TIMEOUT || '10000', 10), +}; + +// Investment configuration +export const investmentConfig = { + // Minimum allocation amount per agent + minAllocationAmount: parseFloat(process.env.MIN_ALLOCATION_AMOUNT || '100'), + // Maximum allocation amount per agent + maxAllocationAmount: parseFloat(process.env.MAX_ALLOCATION_AMOUNT || '100000'), + // Minimum withdrawal amount + minWithdrawalAmount: parseFloat(process.env.MIN_WITHDRAWAL_AMOUNT || '10'), + // Profit distribution frequency (days) + profitDistributionFrequencyDays: parseInt(process.env.PROFIT_DISTRIBUTION_FREQUENCY_DAYS || '30', 10), + // Lock period for new allocations (days) + allocationLockPeriodDays: parseInt(process.env.ALLOCATION_LOCK_PERIOD_DAYS || '7', 10), + // Early withdrawal penalty percentage + earlyWithdrawalPenaltyPercent: parseFloat(process.env.EARLY_WITHDRAWAL_PENALTY_PERCENT || '5'), + // Agent types + agentTypes: ['ATLAS', 'ORION', 'NOVA'] as const, +}; + +// Agent default configurations +export const agentDefaults = { + ATLAS: { + name: 'Atlas', + riskLevel: 'conservative', + targetReturnPercent: 8, + maxDrawdownPercent: 5, + managementFeePercent: 1.5, + performanceFeePercent: 15, + }, + ORION: { + name: 'Orion', + riskLevel: 'moderate', + targetReturnPercent: 15, + maxDrawdownPercent: 12, + managementFeePercent: 2.0, + performanceFeePercent: 20, + }, + NOVA: { + name: 'Nova', + riskLevel: 'aggressive', + targetReturnPercent: 25, + maxDrawdownPercent: 25, + managementFeePercent: 2.5, + performanceFeePercent: 25, + }, +}; + +// Helper to set tenant context for RLS +export async function setTenantContext(client: any, tenantId: string): Promise { + await client.query(`SET LOCAL app.current_tenant_id = '${tenantId}'`); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eb0fb1a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,298 @@ +/** + * MCP Investment Server + * Agent allocations and profit distribution + */ + +import 'dotenv/config'; +import express, { Request, Response, NextFunction } from 'express'; +import helmet from 'helmet'; +import cors from 'cors'; +import { serverConfig, closePool } from './config'; +import { logger } from './utils/logger'; +import { allToolSchemas, allToolHandlers, listTools } from './tools'; +import { authMiddleware, adminMiddleware } from './middleware'; + +const app = express(); + +// Middleware +app.use(helmet()); +app.use(cors()); +app.use(express.json({ limit: '1mb' })); + +// Request logging +app.use((req: Request, _res: Response, next: NextFunction) => { + logger.debug('Incoming request', { + method: req.method, + path: req.path, + }); + next(); +}); + +// Health check +app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'healthy', service: 'mcp-investment', timestamp: new Date().toISOString() }); +}); + +// MCP Endpoints + +// List available tools +app.get('/mcp/tools', (_req: Request, res: Response) => { + res.json({ tools: listTools() }); +}); + +// Execute tool +app.post('/mcp/tools/:toolName', async (req: Request, res: Response) => { + const { toolName } = req.params; + const handler = allToolHandlers[toolName]; + + if (!handler) { + res.status(404).json({ success: false, error: `Tool '${toolName}' not found`, code: 'TOOL_NOT_FOUND' }); + return; + } + + try { + const result = await handler(req.body); + res.json(result); + } catch (error) { + logger.error('Tool execution error', { toolName, error }); + res.status(500).json({ success: false, error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// ============================================================================ +// REST API Endpoints - Protected by Auth +// ============================================================================ + +// Agent configs (public info, but tenant-scoped) +const agentsRouter = express.Router(); +agentsRouter.use(authMiddleware); + +agentsRouter.get('/', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_list_agents({ tenantId: req.tenantId }); + res.json(result); +}); + +agentsRouter.get('/:agentType', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_get_agent({ + tenantId: req.tenantId, + agentType: req.params.agentType, + }); + res.json(result); +}); + +agentsRouter.get('/:agentType/performance', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_get_agent_performance({ + tenantId: req.tenantId, + agentType: req.params.agentType, + periodDays: req.query.periodDays ? parseInt(req.query.periodDays as string, 10) : undefined, + }); + res.json(result); +}); + +agentsRouter.get('/:agentType/distributions', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_get_distributions({ + tenantId: req.tenantId, + agentType: req.params.agentType, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + }); + res.json(result); +}); + +app.use('/api/v1/agents', agentsRouter); + +// Allocations - Protected +const allocationsRouter = express.Router(); +allocationsRouter.use(authMiddleware); + +// Create allocation +allocationsRouter.post('/', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_create_allocation({ + ...req.body, + tenantId: req.tenantId, + userId: req.userId, + }); + res.status(result.success ? 201 : 400).json(result); +}); + +// List allocations +allocationsRouter.get('/', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_list_allocations({ + tenantId: req.tenantId, + userId: req.query.userId || req.userId, // Default to current user + agentType: req.query.agentType, + status: req.query.status, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined, + }); + res.json(result); +}); + +// Get allocation +allocationsRouter.get('/:allocationId', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_get_allocation({ + tenantId: req.tenantId, + allocationId: req.params.allocationId, + }); + res.json(result); +}); + +// Fund allocation +allocationsRouter.post('/:allocationId/fund', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_fund_allocation({ + ...req.body, + tenantId: req.tenantId, + allocationId: req.params.allocationId, + userId: req.userId, + }); + res.json(result); +}); + +// Withdraw from allocation +allocationsRouter.post('/:allocationId/withdraw', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_withdraw({ + ...req.body, + tenantId: req.tenantId, + allocationId: req.params.allocationId, + userId: req.userId, + }); + res.json(result); +}); + +// Update allocation status +allocationsRouter.patch('/:allocationId/status', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_update_status({ + ...req.body, + tenantId: req.tenantId, + allocationId: req.params.allocationId, + }); + res.json(result); +}); + +// Get allocation transactions +allocationsRouter.get('/:allocationId/transactions', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_get_transactions({ + tenantId: req.tenantId, + allocationId: req.params.allocationId, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined, + }); + res.json(result); +}); + +app.use('/api/v1/allocations', allocationsRouter); + +// User endpoints - Protected +const userRouter = express.Router(); +userRouter.use(authMiddleware); + +// Get current user's allocations +userRouter.get('/me/allocations', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_get_user_allocations({ + tenantId: req.tenantId, + userId: req.userId, + }); + res.json(result); +}); + +// Get current user's investment summary +userRouter.get('/me/investment-summary', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_get_user_summary({ + tenantId: req.tenantId, + userId: req.userId, + }); + res.json(result); +}); + +// Get specific user's allocations (for admins or own data) +userRouter.get('/:userId/allocations', async (req: Request, res: Response) => { + // Users can only see their own allocations unless they're owners + if (req.params.userId !== req.userId && !req.isOwner) { + res.status(403).json({ success: false, error: 'Forbidden', code: 'ACCESS_DENIED' }); + return; + } + const result = await allToolHandlers.investment_get_user_allocations({ + tenantId: req.tenantId, + userId: req.params.userId, + }); + res.json(result); +}); + +// Get specific user's investment summary +userRouter.get('/:userId/investment-summary', async (req: Request, res: Response) => { + if (req.params.userId !== req.userId && !req.isOwner) { + res.status(403).json({ success: false, error: 'Forbidden', code: 'ACCESS_DENIED' }); + return; + } + const result = await allToolHandlers.investment_get_user_summary({ + tenantId: req.tenantId, + userId: req.params.userId, + }); + res.json(result); +}); + +app.use('/api/v1/users', userRouter); + +// Admin endpoints - Protected + Admin only +const adminRouter = express.Router(); +adminRouter.use(authMiddleware); +adminRouter.use(adminMiddleware); + +// Distribute profits (admin only) +adminRouter.post('/distribute-profits', async (req: Request, res: Response) => { + const result = await allToolHandlers.investment_distribute_profits({ + ...req.body, + tenantId: req.tenantId, + distributedBy: req.userId, + }); + res.json(result); +}); + +app.use('/api/v1/admin', adminRouter); + +// ============================================================================ +// Error Handling +// ============================================================================ + +app.use((_req: Request, res: Response) => { + res.status(404).json({ success: false, error: 'Not found' }); +}); + +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error('Unhandled error', { error: err.message, stack: err.stack }); + res.status(500).json({ success: false, error: 'Internal server error' }); +}); + +// ============================================================================ +// Server Startup +// ============================================================================ + +async function shutdown() { + logger.info('Shutting down...'); + await closePool(); + process.exit(0); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +app.listen(serverConfig.port, () => { + logger.info(`MCP Investment Server running on port ${serverConfig.port}`, { + env: serverConfig.env, + tools: Object.keys(allToolSchemas).length, + }); + console.log(` +╔════════════════════════════════════════════════════════════╗ +║ MCP INVESTMENT SERVER ║ +╠════════════════════════════════════════════════════════════╣ +║ Port: ${serverConfig.port} ║ +║ Tools: ${String(Object.keys(allToolSchemas).length).padEnd(12)} ║ +╠════════════════════════════════════════════════════════════╣ +║ /api/v1/agents/* - Agent info ║ +║ /api/v1/allocations/* - Investment allocations ║ +║ /api/v1/users/* - User investments ║ +║ /api/v1/admin/* - Admin operations ║ +╚════════════════════════════════════════════════════════════╝ + `); +}); + +export default app; diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..48d3709 --- /dev/null +++ b/src/middleware/auth.middleware.ts @@ -0,0 +1,114 @@ +/** + * Auth Middleware for MCP Investment + * Verifies JWT tokens and sets user context + */ + +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { logger } from '../utils/logger'; + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production-min-256-bits'; + +export interface JWTPayload { + sub: string; + email: string; + tenantId: string; + isOwner: boolean; + iat: number; + exp: number; +} + +declare global { + namespace Express { + interface Request { + userId?: string; + tenantId?: string; + userEmail?: string; + isOwner?: boolean; + isAuthenticated?: boolean; + } + } +} + +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ + success: false, + error: 'Unauthorized', + code: 'MISSING_TOKEN', + }); + return; + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + + req.userId = decoded.sub; + req.tenantId = decoded.tenantId; + req.userEmail = decoded.email; + req.isOwner = decoded.isOwner; + req.isAuthenticated = true; + + req.headers['x-tenant-id'] = decoded.tenantId; + req.headers['x-user-id'] = decoded.sub; + + next(); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + res.status(401).json({ success: false, error: 'Unauthorized', code: 'TOKEN_EXPIRED' }); + return; + } + if (error instanceof jwt.JsonWebTokenError) { + res.status(401).json({ success: false, error: 'Unauthorized', code: 'INVALID_TOKEN' }); + return; + } + logger.error('Auth middleware error', { error }); + res.status(500).json({ success: false, error: 'Internal server error', code: 'AUTH_ERROR' }); + } +} + +export function optionalAuthMiddleware(req: Request, _res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + req.isAuthenticated = false; + next(); + return; + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + req.userId = decoded.sub; + req.tenantId = decoded.tenantId; + req.userEmail = decoded.email; + req.isOwner = decoded.isOwner; + req.isAuthenticated = true; + req.headers['x-tenant-id'] = decoded.tenantId; + req.headers['x-user-id'] = decoded.sub; + } catch { + req.isAuthenticated = false; + } + + next(); +} + +/** + * Admin-only middleware - requires owner or admin role + */ +export function adminMiddleware(req: Request, res: Response, next: NextFunction): void { + if (!req.isOwner) { + res.status(403).json({ + success: false, + error: 'Forbidden', + code: 'ADMIN_REQUIRED', + }); + return; + } + next(); +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..22f5128 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1 @@ +export { authMiddleware, optionalAuthMiddleware, adminMiddleware } from './auth.middleware'; diff --git a/src/services/investment.service.ts b/src/services/investment.service.ts new file mode 100644 index 0000000..d87341a --- /dev/null +++ b/src/services/investment.service.ts @@ -0,0 +1,997 @@ +/** + * Investment Service + * Handles agent allocations, transactions, and profit distributions + */ + +import { Pool, PoolClient } from 'pg'; +import { v4 as uuidv4 } from 'uuid'; +import Decimal from 'decimal.js'; +import { + AgentType, + AllocationStatus, + AgentAllocation, + AllocationWithAgent, + AllocationTransaction, + AgentConfig, + ProfitDistribution, + CreateAllocationInput, + FundAllocationInput, + WithdrawAllocationInput, + UpdateAllocationStatusInput, + DistributeProfitsInput, + AllocationSummary, + AgentPerformanceMetrics, + ListAllocationsFilter, + AllocationTransactionType, +} from '../types/investment.types'; +import { getPool, setTenantContext, investmentConfig, walletServiceConfig } from '../config'; +import { logger } from '../utils/logger'; + +export class InvestmentService { + private pool: Pool; + + constructor() { + this.pool = getPool(); + } + + // ============ Agent Configuration ============ + + async getAgentConfig(agentType: AgentType, tenantId: string): Promise { + const client = await this.pool.connect(); + try { + await setTenantContext(client, tenantId); + const result = await client.query( + `SELECT id, tenant_id as "tenantId", agent_type as "agentType", name, description, + risk_level as "riskLevel", is_active as "isActive", + min_allocation as "minAllocation", max_allocation as "maxAllocation", + target_return_percent as "targetReturnPercent", + max_drawdown_percent as "maxDrawdownPercent", + management_fee_percent as "managementFeePercent", + performance_fee_percent as "performanceFeePercent", + total_aum as "totalAum", total_allocations as "totalAllocations", + metadata, created_at as "createdAt", updated_at as "updatedAt" + FROM investment.agent_configs + WHERE agent_type = $1 AND tenant_id = $2`, + [agentType, tenantId] + ); + return result.rows[0] || null; + } finally { + client.release(); + } + } + + async listAgentConfigs(tenantId: string): Promise { + const client = await this.pool.connect(); + try { + await setTenantContext(client, tenantId); + const result = await client.query( + `SELECT id, tenant_id as "tenantId", agent_type as "agentType", name, description, + risk_level as "riskLevel", is_active as "isActive", + min_allocation as "minAllocation", max_allocation as "maxAllocation", + target_return_percent as "targetReturnPercent", + max_drawdown_percent as "maxDrawdownPercent", + management_fee_percent as "managementFeePercent", + performance_fee_percent as "performanceFeePercent", + total_aum as "totalAum", total_allocations as "totalAllocations", + metadata, created_at as "createdAt", updated_at as "updatedAt" + FROM investment.agent_configs + WHERE tenant_id = $1 AND is_active = true + ORDER BY agent_type`, + [tenantId] + ); + return result.rows; + } finally { + client.release(); + } + } + + // ============ Allocations ============ + + async createAllocation(input: CreateAllocationInput): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await setTenantContext(client, input.tenantId); + + // Validate agent config exists and is active + const agentConfig = await this.getAgentConfigInternal(client, input.agentType, input.tenantId); + if (!agentConfig) { + throw new Error(`Agent ${input.agentType} is not configured for this tenant`); + } + if (!agentConfig.isActive) { + throw new Error(`Agent ${input.agentType} is not active`); + } + + // Validate amount + if (input.amount < agentConfig.minAllocation) { + throw new Error(`Minimum allocation for ${input.agentType} is ${agentConfig.minAllocation}`); + } + if (input.amount > agentConfig.maxAllocation) { + throw new Error(`Maximum allocation for ${input.agentType} is ${agentConfig.maxAllocation}`); + } + + // Check for existing active allocation + const existingResult = await client.query( + `SELECT id FROM investment.agent_allocations + WHERE user_id = $1 AND agent_type = $2 AND status IN ('active', 'pending') + LIMIT 1`, + [input.userId, input.agentType] + ); + if (existingResult.rows.length > 0) { + throw new Error(`User already has an active allocation with ${input.agentType}`); + } + + // Debit wallet first + const walletTxId = await this.debitWallet( + input.walletId, + input.amount, + input.tenantId, + 'AGENT_ALLOCATION', + `Initial allocation to ${input.agentType}`, + null + ); + + // Calculate lock expiration + const lockExpiresAt = new Date(); + lockExpiresAt.setDate(lockExpiresAt.getDate() + investmentConfig.allocationLockPeriodDays); + + // Create allocation + const allocationId = uuidv4(); + const result = await client.query( + `INSERT INTO investment.agent_allocations ( + id, tenant_id, user_id, wallet_id, agent_type, status, + allocated_amount, current_value, total_deposited, + lock_expires_at, metadata + ) VALUES ($1, $2, $3, $4, $5, 'active', $6, $6, $6, $7, $8) + RETURNING id, tenant_id as "tenantId", user_id as "userId", wallet_id as "walletId", + agent_type as "agentType", status, allocated_amount as "allocatedAmount", + current_value as "currentValue", total_deposited as "totalDeposited", + total_withdrawn as "totalWithdrawn", total_profit_distributed as "totalProfitDistributed", + total_fees_paid as "totalFeesPaid", unrealized_pnl as "unrealizedPnl", + realized_pnl as "realizedPnl", last_profit_distribution_at as "lastProfitDistributionAt", + lock_expires_at as "lockExpiresAt", metadata, + created_at as "createdAt", updated_at as "updatedAt"`, + [ + allocationId, + input.tenantId, + input.userId, + input.walletId, + input.agentType, + input.amount, + lockExpiresAt, + input.metadata || {}, + ] + ); + + // Create initial transaction + await this.createTransaction(client, { + allocationId, + tenantId: input.tenantId, + type: 'INITIAL_FUNDING', + amount: input.amount, + balanceBefore: 0, + balanceAfter: input.amount, + walletTransactionId: walletTxId, + description: 'Initial allocation deposit', + }); + + // Update agent AUM + await client.query( + `UPDATE investment.agent_configs + SET total_aum = total_aum + $1, total_allocations = total_allocations + 1, updated_at = NOW() + WHERE agent_type = $2 AND tenant_id = $3`, + [input.amount, input.agentType, input.tenantId] + ); + + await client.query('COMMIT'); + + const allocation = result.rows[0]; + return { + ...allocation, + agentName: agentConfig.name, + agentRiskLevel: agentConfig.riskLevel, + agentTargetReturn: agentConfig.targetReturnPercent, + }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async getAllocation(allocationId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + try { + await setTenantContext(client, tenantId); + const result = await client.query( + `SELECT a.id, a.tenant_id as "tenantId", a.user_id as "userId", a.wallet_id as "walletId", + a.agent_type as "agentType", a.status, a.allocated_amount as "allocatedAmount", + a.current_value as "currentValue", a.total_deposited as "totalDeposited", + a.total_withdrawn as "totalWithdrawn", a.total_profit_distributed as "totalProfitDistributed", + a.total_fees_paid as "totalFeesPaid", a.unrealized_pnl as "unrealizedPnl", + a.realized_pnl as "realizedPnl", a.last_profit_distribution_at as "lastProfitDistributionAt", + a.lock_expires_at as "lockExpiresAt", a.metadata, + a.created_at as "createdAt", a.updated_at as "updatedAt", + c.name as "agentName", c.risk_level as "agentRiskLevel", + c.target_return_percent as "agentTargetReturn" + FROM investment.agent_allocations a + JOIN investment.agent_configs c ON a.agent_type = c.agent_type AND a.tenant_id = c.tenant_id + WHERE a.id = $1 AND a.tenant_id = $2`, + [allocationId, tenantId] + ); + return result.rows[0] || null; + } finally { + client.release(); + } + } + + async listAllocations(filter: ListAllocationsFilter): Promise { + const client = await this.pool.connect(); + try { + await setTenantContext(client, filter.tenantId); + + const conditions: string[] = ['a.tenant_id = $1']; + const params: unknown[] = [filter.tenantId]; + let paramIndex = 2; + + if (filter.userId) { + conditions.push(`a.user_id = $${paramIndex++}`); + params.push(filter.userId); + } + if (filter.agentType) { + conditions.push(`a.agent_type = $${paramIndex++}`); + params.push(filter.agentType); + } + if (filter.status) { + conditions.push(`a.status = $${paramIndex++}`); + params.push(filter.status); + } + + const limit = filter.limit || 50; + const offset = filter.offset || 0; + + const result = await client.query( + `SELECT a.id, a.tenant_id as "tenantId", a.user_id as "userId", a.wallet_id as "walletId", + a.agent_type as "agentType", a.status, a.allocated_amount as "allocatedAmount", + a.current_value as "currentValue", a.total_deposited as "totalDeposited", + a.total_withdrawn as "totalWithdrawn", a.total_profit_distributed as "totalProfitDistributed", + a.total_fees_paid as "totalFeesPaid", a.unrealized_pnl as "unrealizedPnl", + a.realized_pnl as "realizedPnl", a.last_profit_distribution_at as "lastProfitDistributionAt", + a.lock_expires_at as "lockExpiresAt", a.metadata, + a.created_at as "createdAt", a.updated_at as "updatedAt", + c.name as "agentName", c.risk_level as "agentRiskLevel", + c.target_return_percent as "agentTargetReturn" + FROM investment.agent_allocations a + JOIN investment.agent_configs c ON a.agent_type = c.agent_type AND a.tenant_id = c.tenant_id + WHERE ${conditions.join(' AND ')} + ORDER BY a.created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex}`, + [...params, limit, offset] + ); + return result.rows; + } finally { + client.release(); + } + } + + async getUserAllocations(userId: string, tenantId: string): Promise { + return this.listAllocations({ tenantId, userId }); + } + + async fundAllocation(input: FundAllocationInput): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await setTenantContext(client, input.tenantId); + + // Get allocation + const allocation = await this.getAllocationInternal(client, input.allocationId, input.tenantId); + if (!allocation) { + throw new Error('Allocation not found'); + } + if (allocation.status !== 'active') { + throw new Error('Can only fund active allocations'); + } + + // Validate amount + if (input.amount < investmentConfig.minAllocationAmount) { + throw new Error(`Minimum funding amount is ${investmentConfig.minAllocationAmount}`); + } + + // Check max allocation + const agentConfig = await this.getAgentConfigInternal(client, allocation.agentType, input.tenantId); + const newTotal = new Decimal(allocation.currentValue).plus(input.amount); + if (newTotal.greaterThan(agentConfig!.maxAllocation)) { + throw new Error(`Would exceed maximum allocation of ${agentConfig!.maxAllocation}`); + } + + // Debit wallet + const walletTxId = await this.debitWallet( + allocation.walletId, + input.amount, + input.tenantId, + 'AGENT_ALLOCATION', + input.description || `Additional funding to ${allocation.agentType}`, + allocation.id + ); + + // Update allocation + const result = await client.query( + `UPDATE investment.agent_allocations + SET allocated_amount = allocated_amount + $1, + current_value = current_value + $1, + total_deposited = total_deposited + $1, + updated_at = NOW() + WHERE id = $2 AND tenant_id = $3 + RETURNING *`, + [input.amount, input.allocationId, input.tenantId] + ); + + // Create transaction + await this.createTransaction(client, { + allocationId: input.allocationId, + tenantId: input.tenantId, + type: 'ADDITIONAL_FUNDING', + amount: input.amount, + balanceBefore: allocation.currentValue, + balanceAfter: newTotal.toNumber(), + walletTransactionId: walletTxId, + description: input.description || 'Additional deposit', + }); + + // Update agent AUM + await client.query( + `UPDATE investment.agent_configs + SET total_aum = total_aum + $1, updated_at = NOW() + WHERE agent_type = $2 AND tenant_id = $3`, + [input.amount, allocation.agentType, input.tenantId] + ); + + await client.query('COMMIT'); + + return (await this.getAllocation(input.allocationId, input.tenantId))!; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async withdrawFromAllocation(input: WithdrawAllocationInput): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await setTenantContext(client, input.tenantId); + + // Get allocation + const allocation = await this.getAllocationInternal(client, input.allocationId, input.tenantId); + if (!allocation) { + throw new Error('Allocation not found'); + } + if (allocation.status !== 'active') { + throw new Error('Can only withdraw from active allocations'); + } + + // Validate amount + if (input.amount < investmentConfig.minWithdrawalAmount) { + throw new Error(`Minimum withdrawal amount is ${investmentConfig.minWithdrawalAmount}`); + } + if (input.amount > allocation.currentValue) { + throw new Error('Insufficient allocation balance'); + } + + let withdrawAmount = input.amount; + let penaltyAmount = 0; + + // Check if under lock period + if (allocation.lockExpiresAt && new Date() < allocation.lockExpiresAt) { + penaltyAmount = new Decimal(input.amount) + .times(investmentConfig.earlyWithdrawalPenaltyPercent) + .dividedBy(100) + .toNumber(); + withdrawAmount = new Decimal(input.amount).minus(penaltyAmount).toNumber(); + logger.info('Early withdrawal penalty applied', { allocationId: input.allocationId, penaltyAmount }); + } + + const newBalance = new Decimal(allocation.currentValue).minus(input.amount).toNumber(); + + // Credit wallet if requested + let walletTxId: string | null = null; + if (input.withdrawToWallet !== false) { + walletTxId = await this.creditWallet( + allocation.walletId, + withdrawAmount, + input.tenantId, + 'AGENT_WITHDRAWAL', + input.description || `Withdrawal from ${allocation.agentType}`, + allocation.id + ); + } + + // Update allocation + await client.query( + `UPDATE investment.agent_allocations + SET current_value = current_value - $1, + total_withdrawn = total_withdrawn + $2, + updated_at = NOW() + WHERE id = $3 AND tenant_id = $4`, + [input.amount, withdrawAmount, input.allocationId, input.tenantId] + ); + + // Create withdrawal transaction (PARTIAL or FULL based on remaining balance) + const isFullWithdrawal = newBalance <= 0; + await this.createTransaction(client, { + allocationId: input.allocationId, + tenantId: input.tenantId, + type: isFullWithdrawal ? 'FULL_WITHDRAWAL' : 'PARTIAL_WITHDRAWAL', + amount: -withdrawAmount, + balanceBefore: allocation.currentValue, + balanceAfter: newBalance, + walletTransactionId: walletTxId, + description: input.description || 'Withdrawal', + }); + + // Create fee transaction if penalty applicable + if (penaltyAmount > 0) { + await this.createTransaction(client, { + allocationId: input.allocationId, + tenantId: input.tenantId, + type: 'FEE_CHARGED', + amount: -penaltyAmount, + balanceBefore: allocation.currentValue, + balanceAfter: newBalance, + description: 'Early withdrawal penalty', + }); + } + + // Update agent AUM + await client.query( + `UPDATE investment.agent_configs + SET total_aum = total_aum - $1, updated_at = NOW() + WHERE agent_type = $2 AND tenant_id = $3`, + [input.amount, allocation.agentType, input.tenantId] + ); + + // Close allocation if fully withdrawn + if (newBalance <= 0) { + await client.query( + `UPDATE investment.agent_allocations + SET status = 'closed', updated_at = NOW() + WHERE id = $1 AND tenant_id = $2`, + [input.allocationId, input.tenantId] + ); + + await client.query( + `UPDATE investment.agent_configs + SET total_allocations = total_allocations - 1, updated_at = NOW() + WHERE agent_type = $1 AND tenant_id = $2`, + [allocation.agentType, input.tenantId] + ); + } + + await client.query('COMMIT'); + + return (await this.getAllocation(input.allocationId, input.tenantId))!; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async updateAllocationStatus(input: UpdateAllocationStatusInput): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await setTenantContext(client, input.tenantId); + + const allocation = await this.getAllocationInternal(client, input.allocationId, input.tenantId); + if (!allocation) { + throw new Error('Allocation not found'); + } + + // Validate status transition (lowercase to match DDL enum) + const validTransitions: Record = { + pending: ['active', 'closed'], + active: ['paused', 'liquidating', 'closed'], + paused: ['active', 'closed'], + liquidating: ['closed'], + closed: [], + }; + + if (!validTransitions[allocation.status].includes(input.status)) { + throw new Error(`Cannot transition from ${allocation.status} to ${input.status}`); + } + + await client.query( + `UPDATE investment.agent_allocations + SET status = $1, updated_at = NOW(), + metadata = metadata || $2 + WHERE id = $3 AND tenant_id = $4`, + [ + input.status, + JSON.stringify({ statusReason: input.reason, statusUpdatedAt: new Date().toISOString() }), + input.allocationId, + input.tenantId, + ] + ); + + // Update allocation count if closing + if (input.status === 'closed' && allocation.status !== 'closed') { + await client.query( + `UPDATE investment.agent_configs + SET total_allocations = total_allocations - 1, + total_aum = total_aum - $1, + updated_at = NOW() + WHERE agent_type = $2 AND tenant_id = $3`, + [allocation.currentValue, allocation.agentType, input.tenantId] + ); + } + + await client.query('COMMIT'); + + return (await this.getAllocation(input.allocationId, input.tenantId))!; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + // ============ Transactions ============ + + async getAllocationTransactions( + allocationId: string, + tenantId: string, + limit = 50, + offset = 0 + ): Promise { + const client = await this.pool.connect(); + try { + await setTenantContext(client, tenantId); + const result = await client.query( + `SELECT id, tenant_id as "tenantId", allocation_id as "allocationId", + type, amount, balance_before as "balanceBefore", balance_after as "balanceAfter", + wallet_transaction_id as "walletTransactionId", description, metadata, + created_at as "createdAt" + FROM investment.allocation_transactions + WHERE allocation_id = $1 AND tenant_id = $2 + ORDER BY created_at DESC + LIMIT $3 OFFSET $4`, + [allocationId, tenantId, limit, offset] + ); + return result.rows; + } finally { + client.release(); + } + } + + // ============ Profit Distribution ============ + + async distributeProfits(input: DistributeProfitsInput): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await setTenantContext(client, input.tenantId); + + const agentConfig = await this.getAgentConfigInternal(client, input.agentType, input.tenantId); + if (!agentConfig) { + throw new Error(`Agent ${input.agentType} not configured`); + } + + // Get all active allocations for this agent + const allocationsResult = await client.query( + `SELECT id, current_value as "currentValue", user_id as "userId", wallet_id as "walletId" + FROM investment.agent_allocations + WHERE agent_type = $1 AND tenant_id = $2 AND status = 'active'`, + [input.agentType, input.tenantId] + ); + + if (allocationsResult.rows.length === 0) { + throw new Error('No active allocations for this agent'); + } + + const totalAum = allocationsResult.rows.reduce( + (sum, a) => new Decimal(sum).plus(a.currentValue).toNumber(), + 0 + ); + + // Calculate fees + const managementFees = new Decimal(totalAum) + .times(agentConfig.managementFeePercent) + .dividedBy(100) + .dividedBy(12) // Monthly fee from annual rate + .toNumber(); + + let performanceFees = 0; + if (input.totalPnl > 0) { + performanceFees = new Decimal(input.totalPnl) + .times(agentConfig.performanceFeePercent) + .dividedBy(100) + .toNumber(); + } + + const netDistribution = new Decimal(input.totalPnl) + .minus(managementFees) + .minus(performanceFees) + .toNumber(); + + // Create distribution record + const distributionId = uuidv4(); + const distributionResult = await client.query( + `INSERT INTO investment.profit_distributions ( + id, tenant_id, agent_type, period_start, period_end, status, + total_pnl, management_fees, performance_fees, net_distribution, + allocations_count, processed_at + ) VALUES ($1, $2, $3, $4, $5, 'COMPLETED', $6, $7, $8, $9, $10, NOW()) + RETURNING id, tenant_id as "tenantId", agent_type as "agentType", + period_start as "periodStart", period_end as "periodEnd", status, + total_pnl as "totalPnl", management_fees as "managementFees", + performance_fees as "performanceFees", net_distribution as "netDistribution", + allocations_count as "allocationsCount", processed_at as "processedAt", + metadata, created_at as "createdAt"`, + [ + distributionId, + input.tenantId, + input.agentType, + input.periodStart, + input.periodEnd, + input.totalPnl, + managementFees, + performanceFees, + netDistribution, + allocationsResult.rows.length, + ] + ); + + // Distribute to each allocation proportionally + for (const allocation of allocationsResult.rows) { + const weight = new Decimal(allocation.currentValue).dividedBy(totalAum); + const allocationProfit = weight.times(netDistribution).toDecimalPlaces(4).toNumber(); + const allocationFees = weight.times(managementFees).plus(weight.times(performanceFees)).toDecimalPlaces(4).toNumber(); + + const newValue = new Decimal(allocation.currentValue).plus(allocationProfit).toNumber(); + + // Update allocation + await client.query( + `UPDATE investment.agent_allocations + SET current_value = $1, + total_profit_distributed = total_profit_distributed + $2, + total_fees_paid = total_fees_paid + $3, + realized_pnl = realized_pnl + $2, + last_profit_distribution_at = NOW(), + updated_at = NOW() + WHERE id = $4 AND tenant_id = $5`, + [newValue, allocationProfit, allocationFees, allocation.id, input.tenantId] + ); + + // Create profit/loss distribution transaction + if (allocationProfit !== 0) { + await this.createTransaction(client, { + allocationId: allocation.id, + tenantId: input.tenantId, + type: allocationProfit > 0 ? 'PROFIT_REALIZED' : 'LOSS_REALIZED', + amount: allocationProfit, + balanceBefore: allocation.currentValue, + balanceAfter: newValue, + description: `P/L distribution for ${input.periodStart.toISOString().split('T')[0]} to ${input.periodEnd.toISOString().split('T')[0]}`, + metadata: { distributionId }, + }); + } + + // Create fee transactions + if (allocationFees > 0) { + await this.createTransaction(client, { + allocationId: allocation.id, + tenantId: input.tenantId, + type: 'FEE_CHARGED', + amount: -allocationFees, + balanceBefore: allocation.currentValue, + balanceAfter: newValue, + description: 'Management and performance fees', + metadata: { distributionId }, + }); + } + } + + await client.query('COMMIT'); + + logger.info('Profit distribution completed', { + distributionId, + agentType: input.agentType, + totalPnl: input.totalPnl, + netDistribution, + allocationsCount: allocationsResult.rows.length, + }); + + return distributionResult.rows[0]; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async getProfitDistributions( + agentType: AgentType, + tenantId: string, + limit = 12 + ): Promise { + const client = await this.pool.connect(); + try { + await setTenantContext(client, tenantId); + const result = await client.query( + `SELECT id, tenant_id as "tenantId", agent_type as "agentType", + period_start as "periodStart", period_end as "periodEnd", status, + total_pnl as "totalPnl", management_fees as "managementFees", + performance_fees as "performanceFees", net_distribution as "netDistribution", + allocations_count as "allocationsCount", processed_at as "processedAt", + metadata, created_at as "createdAt" + FROM investment.profit_distributions + WHERE agent_type = $1 AND tenant_id = $2 + ORDER BY period_end DESC + LIMIT $3`, + [agentType, tenantId, limit] + ); + return result.rows; + } finally { + client.release(); + } + } + + // ============ Statistics ============ + + async getUserAllocationSummary(userId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + try { + await setTenantContext(client, tenantId); + + const allocations = await this.listAllocations({ tenantId, userId, status: 'active' }); + + const totalAllocated = allocations.reduce( + (sum, a) => new Decimal(sum).plus(a.allocatedAmount).toNumber(), + 0 + ); + const currentValue = allocations.reduce( + (sum, a) => new Decimal(sum).plus(a.currentValue).toNumber(), + 0 + ); + const totalPnl = new Decimal(currentValue).minus(totalAllocated).toNumber(); + const totalPnlPercent = totalAllocated > 0 + ? new Decimal(totalPnl).dividedBy(totalAllocated).times(100).toDecimalPlaces(2).toNumber() + : 0; + + // Group by agent + const byAgent = new Map(); + for (const allocation of allocations) { + const existing = byAgent.get(allocation.agentType) || { count: 0, totalValue: 0, pnl: 0 }; + byAgent.set(allocation.agentType, { + count: existing.count + 1, + totalValue: new Decimal(existing.totalValue).plus(allocation.currentValue).toNumber(), + pnl: new Decimal(existing.pnl).plus(allocation.realizedPnl).plus(allocation.unrealizedPnl).toNumber(), + }); + } + + return { + totalAllocated, + currentValue, + totalPnl, + totalPnlPercent, + allocationsByAgent: Array.from(byAgent.entries()).map(([agentType, data]) => ({ + agentType, + ...data, + })), + }; + } finally { + client.release(); + } + } + + async getAgentPerformance( + agentType: AgentType, + tenantId: string, + periodDays = 30 + ): Promise { + const client = await this.pool.connect(); + try { + await setTenantContext(client, tenantId); + + const agentConfig = await this.getAgentConfigInternal(client, agentType, tenantId); + if (!agentConfig) { + throw new Error(`Agent ${agentType} not configured`); + } + + // Get recent profit distributions + const periodStart = new Date(); + periodStart.setDate(periodStart.getDate() - periodDays); + + const distributionsResult = await client.query<{ totalPnl: number }>( + `SELECT COALESCE(SUM(total_pnl), 0) as "totalPnl" + FROM investment.profit_distributions + WHERE agent_type = $1 AND tenant_id = $2 AND period_end >= $3 AND status = 'COMPLETED'`, + [agentType, tenantId, periodStart] + ); + + const periodReturn = distributionsResult.rows[0]?.totalPnl || 0; + const periodReturnPercent = agentConfig.totalAum > 0 + ? new Decimal(periodReturn).dividedBy(agentConfig.totalAum).times(100).toDecimalPlaces(2).toNumber() + : 0; + + return { + agentType, + periodDays, + totalAum: agentConfig.totalAum, + totalAllocations: agentConfig.totalAllocations, + periodReturn, + periodReturnPercent, + maxDrawdown: agentConfig.maxDrawdownPercent, + sharpeRatio: null, // Would require more historical data to calculate + winRate: null, + }; + } finally { + client.release(); + } + } + + // ============ Private Helpers ============ + + private async getAgentConfigInternal( + client: PoolClient, + agentType: AgentType, + tenantId: string + ): Promise { + const result = await client.query( + `SELECT id, tenant_id as "tenantId", agent_type as "agentType", name, description, + risk_level as "riskLevel", is_active as "isActive", + min_allocation as "minAllocation", max_allocation as "maxAllocation", + target_return_percent as "targetReturnPercent", + max_drawdown_percent as "maxDrawdownPercent", + management_fee_percent as "managementFeePercent", + performance_fee_percent as "performanceFeePercent", + total_aum as "totalAum", total_allocations as "totalAllocations", + metadata, created_at as "createdAt", updated_at as "updatedAt" + FROM investment.agent_configs + WHERE agent_type = $1 AND tenant_id = $2`, + [agentType, tenantId] + ); + return result.rows[0] || null; + } + + private async getAllocationInternal( + client: PoolClient, + allocationId: string, + tenantId: string + ): Promise { + const result = await client.query( + `SELECT id, tenant_id as "tenantId", user_id as "userId", wallet_id as "walletId", + agent_type as "agentType", status, allocated_amount as "allocatedAmount", + current_value as "currentValue", total_deposited as "totalDeposited", + total_withdrawn as "totalWithdrawn", total_profit_distributed as "totalProfitDistributed", + total_fees_paid as "totalFeesPaid", unrealized_pnl as "unrealizedPnl", + realized_pnl as "realizedPnl", last_profit_distribution_at as "lastProfitDistributionAt", + lock_expires_at as "lockExpiresAt", metadata, + created_at as "createdAt", updated_at as "updatedAt" + FROM investment.agent_allocations + WHERE id = $1 AND tenant_id = $2`, + [allocationId, tenantId] + ); + return result.rows[0] || null; + } + + private async createTransaction( + client: PoolClient, + data: { + allocationId: string; + tenantId: string; + type: AllocationTransactionType; + amount: number; + balanceBefore: number; + balanceAfter: number; + walletTransactionId?: string | null; + description?: string; + metadata?: Record; + } + ): Promise { + const txId = uuidv4(); + await client.query( + `INSERT INTO investment.allocation_transactions ( + id, tenant_id, allocation_id, type, amount, balance_before, balance_after, + wallet_transaction_id, description, metadata + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + txId, + data.tenantId, + data.allocationId, + data.type, + data.amount, + data.balanceBefore, + data.balanceAfter, + data.walletTransactionId || null, + data.description || null, + data.metadata || {}, + ] + ); + return txId; + } + + private async debitWallet( + walletId: string, + amount: number, + tenantId: string, + type: string, + description: string, + referenceId: string | null + ): Promise { + const response = await fetch(`${walletServiceConfig.baseUrl}/api/v1/wallets/${walletId}/debit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-Id': tenantId, + }, + body: JSON.stringify({ + tenantId, + amount, + type, + description, + referenceType: 'AGENT_ALLOCATION', + referenceId, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.message || 'Failed to debit wallet'); + } + + const result = await response.json(); + return result.data.transactionId; + } + + private async creditWallet( + walletId: string, + amount: number, + tenantId: string, + type: string, + description: string, + referenceId: string | null + ): Promise { + const response = await fetch(`${walletServiceConfig.baseUrl}/api/v1/wallets/${walletId}/credit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-Id': tenantId, + }, + body: JSON.stringify({ + tenantId, + amount, + type, + description, + referenceType: 'AGENT_WITHDRAWAL', + referenceId, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.message || 'Failed to credit wallet'); + } + + const result = await response.json(); + return result.data.transactionId; + } +} + +// Singleton instance +let investmentServiceInstance: InvestmentService | null = null; + +export function getInvestmentService(): InvestmentService { + if (!investmentServiceInstance) { + investmentServiceInstance = new InvestmentService(); + } + return investmentServiceInstance; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..9e6abbc --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,31 @@ +/** + * Tool Registry Index + * Exports all investment tools and handlers + */ + +import { investmentToolSchemas, toolHandlers } from './investment'; + +// Export all tool schemas +export const allToolSchemas = { + ...investmentToolSchemas, +}; + +// Export all handlers +export const allToolHandlers = { + ...toolHandlers, +}; + +// Get tool by name +export function getToolSchema(name: string) { + return (allToolSchemas as Record)[name]; +} + +// Get handler by name +export function getToolHandler(name: string) { + return allToolHandlers[name]; +} + +// List all available tools +export function listTools() { + return Object.values(allToolSchemas); +} diff --git a/src/tools/investment.ts b/src/tools/investment.ts new file mode 100644 index 0000000..e688077 --- /dev/null +++ b/src/tools/investment.ts @@ -0,0 +1,513 @@ +/** + * Investment MCP Tools + * Agent allocation and profit distribution tools + */ + +import { z } from 'zod'; +import { getInvestmentService } from '../services/investment.service'; +import { McpResponse, AgentType, AllocationStatus } from '../types/investment.types'; +import { logger } from '../utils/logger'; + +// Validation schemas (aligned with DDL enums) +const AgentTypeSchema = z.enum(['ATLAS', 'ORION', 'NOVA']); +const AllocationStatusSchema = z.enum(['pending', 'active', 'paused', 'liquidating', 'closed']); + +const CreateAllocationSchema = z.object({ + tenantId: z.string().uuid(), + userId: z.string().uuid(), + walletId: z.string().uuid(), + agentType: AgentTypeSchema, + amount: z.number().positive(), + metadata: z.record(z.unknown()).optional(), +}); + +const FundAllocationSchema = z.object({ + tenantId: z.string().uuid(), + allocationId: z.string().uuid(), + amount: z.number().positive(), + description: z.string().optional(), +}); + +const WithdrawAllocationSchema = z.object({ + tenantId: z.string().uuid(), + allocationId: z.string().uuid(), + amount: z.number().positive(), + withdrawToWallet: z.boolean().optional(), + description: z.string().optional(), +}); + +const UpdateStatusSchema = z.object({ + tenantId: z.string().uuid(), + allocationId: z.string().uuid(), + status: AllocationStatusSchema, + reason: z.string().optional(), +}); + +const DistributeProfitsSchema = z.object({ + tenantId: z.string().uuid(), + agentType: AgentTypeSchema, + periodStart: z.string().transform((s) => new Date(s)), + periodEnd: z.string().transform((s) => new Date(s)), + totalPnl: z.number(), +}); + +const ListAllocationsSchema = z.object({ + tenantId: z.string().uuid(), + userId: z.string().uuid().optional(), + agentType: AgentTypeSchema.optional(), + status: AllocationStatusSchema.optional(), + limit: z.number().int().positive().max(100).optional(), + offset: z.number().int().nonnegative().optional(), +}); + +// Tool schemas for MCP +export const investmentToolSchemas = { + // Agent Configuration + investment_list_agents: { + name: 'investment_list_agents', + description: 'List all available investment agents (Atlas, Orion, Nova) with their configurations', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + }, + required: ['tenantId'], + }, + riskLevel: 'LOW', + }, + + investment_get_agent: { + name: 'investment_get_agent', + description: 'Get specific agent configuration and performance metrics', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + agentType: { type: 'string', enum: ['ATLAS', 'ORION', 'NOVA'], description: 'Agent type' }, + }, + required: ['tenantId', 'agentType'], + }, + riskLevel: 'LOW', + }, + + // Allocations + investment_create_allocation: { + name: 'investment_create_allocation', + description: 'Create a new allocation to an investment agent. Debits amount from user wallet.', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + userId: { type: 'string', format: 'uuid', description: 'User ID' }, + walletId: { type: 'string', format: 'uuid', description: 'Wallet to debit from' }, + agentType: { type: 'string', enum: ['ATLAS', 'ORION', 'NOVA'], description: 'Agent to allocate to' }, + amount: { type: 'number', minimum: 0, description: 'Amount to allocate' }, + metadata: { type: 'object', description: 'Optional metadata' }, + }, + required: ['tenantId', 'userId', 'walletId', 'agentType', 'amount'], + }, + riskLevel: 'HIGH', + }, + + investment_get_allocation: { + name: 'investment_get_allocation', + description: 'Get details of a specific allocation', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + allocationId: { type: 'string', format: 'uuid', description: 'Allocation ID' }, + }, + required: ['tenantId', 'allocationId'], + }, + riskLevel: 'LOW', + }, + + investment_list_allocations: { + name: 'investment_list_allocations', + description: 'List allocations with optional filters', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + userId: { type: 'string', format: 'uuid', description: 'Filter by user ID' }, + agentType: { type: 'string', enum: ['ATLAS', 'ORION', 'NOVA'], description: 'Filter by agent' }, + status: { type: 'string', enum: ['pending', 'active', 'paused', 'liquidating', 'closed'] }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 }, + offset: { type: 'integer', minimum: 0, default: 0 }, + }, + required: ['tenantId'], + }, + riskLevel: 'LOW', + }, + + investment_get_user_allocations: { + name: 'investment_get_user_allocations', + description: 'Get all allocations for a specific user', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + userId: { type: 'string', format: 'uuid', description: 'User ID' }, + }, + required: ['tenantId', 'userId'], + }, + riskLevel: 'LOW', + }, + + investment_fund_allocation: { + name: 'investment_fund_allocation', + description: 'Add more funds to an existing allocation. Debits from user wallet.', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + allocationId: { type: 'string', format: 'uuid', description: 'Allocation ID' }, + amount: { type: 'number', minimum: 0, description: 'Amount to add' }, + description: { type: 'string', description: 'Optional description' }, + }, + required: ['tenantId', 'allocationId', 'amount'], + }, + riskLevel: 'HIGH', + }, + + investment_withdraw: { + name: 'investment_withdraw', + description: 'Withdraw funds from an allocation. Credits to user wallet. Early withdrawal may incur penalty.', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + allocationId: { type: 'string', format: 'uuid', description: 'Allocation ID' }, + amount: { type: 'number', minimum: 0, description: 'Amount to withdraw' }, + withdrawToWallet: { type: 'boolean', default: true, description: 'Credit to wallet' }, + description: { type: 'string', description: 'Optional description' }, + }, + required: ['tenantId', 'allocationId', 'amount'], + }, + riskLevel: 'HIGH', + }, + + investment_update_status: { + name: 'investment_update_status', + description: 'Update allocation status (pause, resume, close)', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + allocationId: { type: 'string', format: 'uuid', description: 'Allocation ID' }, + status: { type: 'string', enum: ['pending', 'active', 'paused', 'liquidating', 'closed'] }, + reason: { type: 'string', description: 'Reason for status change' }, + }, + required: ['tenantId', 'allocationId', 'status'], + }, + riskLevel: 'MEDIUM', + }, + + // Transactions + investment_get_transactions: { + name: 'investment_get_transactions', + description: 'Get transaction history for an allocation', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + allocationId: { type: 'string', format: 'uuid', description: 'Allocation ID' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 }, + offset: { type: 'integer', minimum: 0, default: 0 }, + }, + required: ['tenantId', 'allocationId'], + }, + riskLevel: 'LOW', + }, + + // Profit Distribution + investment_distribute_profits: { + name: 'investment_distribute_profits', + description: 'Distribute profits to all allocations for an agent (admin operation)', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + agentType: { type: 'string', enum: ['ATLAS', 'ORION', 'NOVA'], description: 'Agent type' }, + periodStart: { type: 'string', format: 'date-time', description: 'Period start date' }, + periodEnd: { type: 'string', format: 'date-time', description: 'Period end date' }, + totalPnl: { type: 'number', description: 'Total PnL for the period' }, + }, + required: ['tenantId', 'agentType', 'periodStart', 'periodEnd', 'totalPnl'], + }, + riskLevel: 'CRITICAL', + }, + + investment_get_distributions: { + name: 'investment_get_distributions', + description: 'Get profit distribution history for an agent', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + agentType: { type: 'string', enum: ['ATLAS', 'ORION', 'NOVA'], description: 'Agent type' }, + limit: { type: 'integer', minimum: 1, maximum: 24, default: 12 }, + }, + required: ['tenantId', 'agentType'], + }, + riskLevel: 'LOW', + }, + + // Statistics + investment_get_user_summary: { + name: 'investment_get_user_summary', + description: 'Get investment summary for a user across all agents', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + userId: { type: 'string', format: 'uuid', description: 'User ID' }, + }, + required: ['tenantId', 'userId'], + }, + riskLevel: 'LOW', + }, + + investment_get_agent_performance: { + name: 'investment_get_agent_performance', + description: 'Get performance metrics for an agent', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', format: 'uuid', description: 'Tenant ID' }, + agentType: { type: 'string', enum: ['ATLAS', 'ORION', 'NOVA'], description: 'Agent type' }, + periodDays: { type: 'integer', minimum: 1, maximum: 365, default: 30 }, + }, + required: ['tenantId', 'agentType'], + }, + riskLevel: 'LOW', + }, +}; + +// Tool handlers +async function handleListAgents(params: unknown): Promise { + try { + const { tenantId } = z.object({ tenantId: z.string().uuid() }).parse(params); + const service = getInvestmentService(); + const agents = await service.listAgentConfigs(tenantId); + return { success: true, data: agents }; + } catch (error) { + logger.error('Error listing agents', { error }); + return { success: false, error: (error as Error).message, code: 'LIST_AGENTS_ERROR' }; + } +} + +async function handleGetAgent(params: unknown): Promise { + try { + const { tenantId, agentType } = z.object({ + tenantId: z.string().uuid(), + agentType: AgentTypeSchema, + }).parse(params); + const service = getInvestmentService(); + const agent = await service.getAgentConfig(agentType, tenantId); + if (!agent) { + return { success: false, error: 'Agent not found', code: 'AGENT_NOT_FOUND' }; + } + return { success: true, data: agent }; + } catch (error) { + logger.error('Error getting agent', { error }); + return { success: false, error: (error as Error).message, code: 'GET_AGENT_ERROR' }; + } +} + +async function handleCreateAllocation(params: unknown): Promise { + try { + const input = CreateAllocationSchema.parse(params); + const service = getInvestmentService(); + const allocation = await service.createAllocation(input); + logger.info('Allocation created', { allocationId: allocation.id, agentType: input.agentType }); + return { success: true, data: allocation }; + } catch (error) { + logger.error('Error creating allocation', { error }); + return { success: false, error: (error as Error).message, code: 'CREATE_ALLOCATION_ERROR' }; + } +} + +async function handleGetAllocation(params: unknown): Promise { + try { + const { tenantId, allocationId } = z.object({ + tenantId: z.string().uuid(), + allocationId: z.string().uuid(), + }).parse(params); + const service = getInvestmentService(); + const allocation = await service.getAllocation(allocationId, tenantId); + if (!allocation) { + return { success: false, error: 'Allocation not found', code: 'ALLOCATION_NOT_FOUND' }; + } + return { success: true, data: allocation }; + } catch (error) { + logger.error('Error getting allocation', { error }); + return { success: false, error: (error as Error).message, code: 'GET_ALLOCATION_ERROR' }; + } +} + +async function handleListAllocations(params: unknown): Promise { + try { + const filter = ListAllocationsSchema.parse(params); + const service = getInvestmentService(); + const allocations = await service.listAllocations(filter); + return { success: true, data: allocations }; + } catch (error) { + logger.error('Error listing allocations', { error }); + return { success: false, error: (error as Error).message, code: 'LIST_ALLOCATIONS_ERROR' }; + } +} + +async function handleGetUserAllocations(params: unknown): Promise { + try { + const { tenantId, userId } = z.object({ + tenantId: z.string().uuid(), + userId: z.string().uuid(), + }).parse(params); + const service = getInvestmentService(); + const allocations = await service.getUserAllocations(userId, tenantId); + return { success: true, data: allocations }; + } catch (error) { + logger.error('Error getting user allocations', { error }); + return { success: false, error: (error as Error).message, code: 'GET_USER_ALLOCATIONS_ERROR' }; + } +} + +async function handleFundAllocation(params: unknown): Promise { + try { + const input = FundAllocationSchema.parse(params); + const service = getInvestmentService(); + const allocation = await service.fundAllocation(input); + logger.info('Allocation funded', { allocationId: input.allocationId, amount: input.amount }); + return { success: true, data: allocation }; + } catch (error) { + logger.error('Error funding allocation', { error }); + return { success: false, error: (error as Error).message, code: 'FUND_ALLOCATION_ERROR' }; + } +} + +async function handleWithdraw(params: unknown): Promise { + try { + const input = WithdrawAllocationSchema.parse(params); + const service = getInvestmentService(); + const allocation = await service.withdrawFromAllocation(input); + logger.info('Withdrawal processed', { allocationId: input.allocationId, amount: input.amount }); + return { success: true, data: allocation }; + } catch (error) { + logger.error('Error withdrawing from allocation', { error }); + return { success: false, error: (error as Error).message, code: 'WITHDRAW_ERROR' }; + } +} + +async function handleUpdateStatus(params: unknown): Promise { + try { + const input = UpdateStatusSchema.parse(params); + const service = getInvestmentService(); + const allocation = await service.updateAllocationStatus(input); + logger.info('Allocation status updated', { allocationId: input.allocationId, status: input.status }); + return { success: true, data: allocation }; + } catch (error) { + logger.error('Error updating allocation status', { error }); + return { success: false, error: (error as Error).message, code: 'UPDATE_STATUS_ERROR' }; + } +} + +async function handleGetTransactions(params: unknown): Promise { + try { + const { tenantId, allocationId, limit, offset } = z.object({ + tenantId: z.string().uuid(), + allocationId: z.string().uuid(), + limit: z.number().int().positive().max(100).optional(), + offset: z.number().int().nonnegative().optional(), + }).parse(params); + const service = getInvestmentService(); + const transactions = await service.getAllocationTransactions(allocationId, tenantId, limit, offset); + return { success: true, data: transactions }; + } catch (error) { + logger.error('Error getting transactions', { error }); + return { success: false, error: (error as Error).message, code: 'GET_TRANSACTIONS_ERROR' }; + } +} + +async function handleDistributeProfits(params: unknown): Promise { + try { + const input = DistributeProfitsSchema.parse(params); + const service = getInvestmentService(); + const distribution = await service.distributeProfits(input); + logger.info('Profits distributed', { + distributionId: distribution.id, + agentType: input.agentType, + totalPnl: input.totalPnl, + }); + return { success: true, data: distribution }; + } catch (error) { + logger.error('Error distributing profits', { error }); + return { success: false, error: (error as Error).message, code: 'DISTRIBUTE_PROFITS_ERROR' }; + } +} + +async function handleGetDistributions(params: unknown): Promise { + try { + const { tenantId, agentType, limit } = z.object({ + tenantId: z.string().uuid(), + agentType: AgentTypeSchema, + limit: z.number().int().positive().max(24).optional(), + }).parse(params); + const service = getInvestmentService(); + const distributions = await service.getProfitDistributions(agentType, tenantId, limit); + return { success: true, data: distributions }; + } catch (error) { + logger.error('Error getting distributions', { error }); + return { success: false, error: (error as Error).message, code: 'GET_DISTRIBUTIONS_ERROR' }; + } +} + +async function handleGetUserSummary(params: unknown): Promise { + try { + const { tenantId, userId } = z.object({ + tenantId: z.string().uuid(), + userId: z.string().uuid(), + }).parse(params); + const service = getInvestmentService(); + const summary = await service.getUserAllocationSummary(userId, tenantId); + return { success: true, data: summary }; + } catch (error) { + logger.error('Error getting user summary', { error }); + return { success: false, error: (error as Error).message, code: 'GET_USER_SUMMARY_ERROR' }; + } +} + +async function handleGetAgentPerformance(params: unknown): Promise { + try { + const { tenantId, agentType, periodDays } = z.object({ + tenantId: z.string().uuid(), + agentType: AgentTypeSchema, + periodDays: z.number().int().positive().max(365).optional(), + }).parse(params); + const service = getInvestmentService(); + const performance = await service.getAgentPerformance(agentType, tenantId, periodDays); + return { success: true, data: performance }; + } catch (error) { + logger.error('Error getting agent performance', { error }); + return { success: false, error: (error as Error).message, code: 'GET_AGENT_PERFORMANCE_ERROR' }; + } +} + +// Tool handlers map +export const toolHandlers: Record Promise> = { + investment_list_agents: handleListAgents, + investment_get_agent: handleGetAgent, + investment_create_allocation: handleCreateAllocation, + investment_get_allocation: handleGetAllocation, + investment_list_allocations: handleListAllocations, + investment_get_user_allocations: handleGetUserAllocations, + investment_fund_allocation: handleFundAllocation, + investment_withdraw: handleWithdraw, + investment_update_status: handleUpdateStatus, + investment_get_transactions: handleGetTransactions, + investment_distribute_profits: handleDistributeProfits, + investment_get_distributions: handleGetDistributions, + investment_get_user_summary: handleGetUserSummary, + investment_get_agent_performance: handleGetAgentPerformance, +}; diff --git a/src/types/investment.types.ts b/src/types/investment.types.ts new file mode 100644 index 0000000..c883250 --- /dev/null +++ b/src/types/investment.types.ts @@ -0,0 +1,201 @@ +/** + * Investment/Agent Allocation Types + */ + +// Agent types +export type AgentType = 'ATLAS' | 'ORION' | 'NOVA'; + +// Allocation status (matches DDL: investment.allocation_status) +export type AllocationStatus = 'pending' | 'active' | 'paused' | 'liquidating' | 'closed'; + +// Transaction types (matches DDL: investment.allocation_tx_type) +export type AllocationTransactionType = + | 'INITIAL_FUNDING' + | 'ADDITIONAL_FUNDING' + | 'PARTIAL_WITHDRAWAL' + | 'FULL_WITHDRAWAL' + | 'PROFIT_REALIZED' + | 'LOSS_REALIZED' + | 'FEE_CHARGED' + | 'REBALANCE'; + +// Profit distribution status +export type ProfitDistributionStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + +// Agent configuration interface +export interface AgentConfig { + id: string; + tenantId: string; + agentType: AgentType; + name: string; + description: string | null; + riskLevel: string; + isActive: boolean; + minAllocation: number; + maxAllocation: number; + targetReturnPercent: number; + maxDrawdownPercent: number; + managementFeePercent: number; + performanceFeePercent: number; + totalAum: number; + totalAllocations: number; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +// Agent allocation interface +export interface AgentAllocation { + id: string; + tenantId: string; + userId: string; + walletId: string; + agentType: AgentType; + status: AllocationStatus; + allocatedAmount: number; + currentValue: number; + totalDeposited: number; + totalWithdrawn: number; + totalProfitDistributed: number; + totalFeesPaid: number; + unrealizedPnl: number; + realizedPnl: number; + lastProfitDistributionAt: Date | null; + lockExpiresAt: Date | null; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +// Allocation transaction interface +export interface AllocationTransaction { + id: string; + tenantId: string; + allocationId: string; + type: AllocationTransactionType; + amount: number; + balanceBefore: number; + balanceAfter: number; + walletTransactionId: string | null; + description: string | null; + metadata: Record; + createdAt: Date; +} + +// Profit distribution interface +export interface ProfitDistribution { + id: string; + tenantId: string; + agentType: AgentType; + periodStart: Date; + periodEnd: Date; + status: ProfitDistributionStatus; + totalPnl: number; + managementFees: number; + performanceFees: number; + netDistribution: number; + allocationsCount: number; + processedAt: Date | null; + metadata: Record; + createdAt: Date; +} + +// Allocation with agent info +export interface AllocationWithAgent extends AgentAllocation { + agentName: string; + agentRiskLevel: string; + agentTargetReturn: number; +} + +// Create allocation input +export interface CreateAllocationInput { + tenantId: string; + userId: string; + walletId: string; + agentType: AgentType; + amount: number; + metadata?: Record; +} + +// Fund allocation input +export interface FundAllocationInput { + tenantId: string; + allocationId: string; + amount: number; + description?: string; +} + +// Withdraw from allocation input +export interface WithdrawAllocationInput { + tenantId: string; + allocationId: string; + amount: number; + withdrawToWallet?: boolean; + description?: string; +} + +// Update allocation status input +export interface UpdateAllocationStatusInput { + tenantId: string; + allocationId: string; + status: AllocationStatus; + reason?: string; +} + +// Distribute profits input +export interface DistributeProfitsInput { + tenantId: string; + agentType: AgentType; + periodStart: Date; + periodEnd: Date; + totalPnl: number; +} + +// Allocation summary +export interface AllocationSummary { + totalAllocated: number; + currentValue: number; + totalPnl: number; + totalPnlPercent: number; + allocationsByAgent: { + agentType: AgentType; + count: number; + totalValue: number; + pnl: number; + }[]; +} + +// Agent performance metrics +export interface AgentPerformanceMetrics { + agentType: AgentType; + periodDays: number; + totalAum: number; + totalAllocations: number; + periodReturn: number; + periodReturnPercent: number; + maxDrawdown: number; + sharpeRatio: number | null; + winRate: number | null; +} + +// Pagination +export interface PaginationParams { + limit?: number; + offset?: number; +} + +// List allocations filter +export interface ListAllocationsFilter extends PaginationParams { + tenantId: string; + userId?: string; + agentType?: AgentType; + status?: AllocationStatus; +} + +// MCP Response format +export interface McpResponse { + success: boolean; + data?: unknown; + error?: string; + code?: string; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..0c3e691 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,38 @@ +/** + * Winston Logger Configuration + */ + +import winston from 'winston'; +import { serverConfig } from '../config'; + +const logFormat = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.printf(({ level, message, timestamp, ...meta }) => { + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; + return `${timestamp} [${level.toUpperCase()}] ${message}${metaStr}`; + }) +); + +export const logger = winston.createLogger({ + level: serverConfig.logLevel, + format: logFormat, + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + logFormat + ), + }), + ], +}); + +// Log unhandled rejections +process.on('unhandledRejection', (reason: unknown) => { + logger.error('Unhandled Rejection:', { reason }); +}); + +process.on('uncaughtException', (error: Error) => { + logger.error('Uncaught Exception:', { error: error.message, stack: error.stack }); + process.exit(1); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..98d4c73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}