added new exchanges system

This commit is contained in:
Boki 2025-06-17 23:19:12 -04:00
parent 95eda4a842
commit 263e9513b7
98 changed files with 4643 additions and 1496 deletions

130
apps/web-api/src/index.ts Normal file
View file

@ -0,0 +1,130 @@
/**
* Stock Bot Web API - REST API service for web application
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadEnvVariables } from '@stock-bot/config';
import { getLogger, shutdownLoggers } from '@stock-bot/logger';
import { connectMongoDB, disconnectMongoDB } from '@stock-bot/mongodb-client';
import { connectPostgreSQL, disconnectPostgreSQL } from '@stock-bot/postgres-client';
import { Shutdown } from '@stock-bot/shutdown';
// Import routes
import { exchangeRoutes } from './routes/exchange.routes';
import { healthRoutes } from './routes/health.routes';
// Load environment variables
loadEnvVariables();
const app = new Hono();
// Add CORS middleware
app.use(
'*',
cors({
origin: ['http://localhost:4200', 'http://localhost:3000'], // React dev server ports
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
})
);
const logger = getLogger('web-api');
const PORT = parseInt(process.env.WEB_API_PORT || '4000');
let server: ReturnType<typeof Bun.serve> | null = null;
// Initialize shutdown manager
const shutdown = Shutdown.getInstance({ timeout: 15000 });
// Add routes
app.route('/health', healthRoutes);
app.route('/api/exchanges', exchangeRoutes);
// Basic API info endpoint
app.get('/', c => {
return c.json({
name: 'Stock Bot Web API',
version: '1.0.0',
status: 'running',
timestamp: new Date().toISOString(),
endpoints: {
health: '/health',
exchanges: '/api/exchanges',
},
});
});
// Initialize services
async function initializeServices() {
logger.info('Initializing web API service...');
try {
// Initialize MongoDB client
logger.info('Connecting to MongoDB...');
await connectMongoDB();
logger.info('MongoDB connected');
// Initialize PostgreSQL client
logger.info('Connecting to PostgreSQL...');
await connectPostgreSQL();
logger.info('PostgreSQL connected');
logger.info('All services initialized successfully');
} catch (error) {
logger.error('Failed to initialize services', { error });
throw error;
}
}
// Start server
async function startServer() {
await initializeServices();
server = Bun.serve({
port: PORT,
fetch: app.fetch,
development: process.env.NODE_ENV === 'development',
});
logger.info(`Stock Bot Web API started on port ${PORT}`);
}
// Register shutdown handlers
shutdown.onShutdown(async () => {
if (server) {
logger.info('Stopping HTTP server...');
try {
server.stop();
logger.info('HTTP server stopped');
} catch (error) {
logger.error('Error stopping HTTP server', { error });
}
}
});
shutdown.onShutdown(async () => {
logger.info('Disconnecting from databases...');
try {
await disconnectMongoDB();
await disconnectPostgreSQL();
logger.info('Database connections closed');
} catch (error) {
logger.error('Error closing database connections', { error });
}
});
shutdown.onShutdown(async () => {
try {
await shutdownLoggers();
process.stdout.write('Web API loggers shut down\n');
} catch (error) {
process.stderr.write(`Error shutting down loggers: ${error}\n`);
}
});
// Start the service
startServer().catch(error => {
logger.error('Failed to start web API service', { error });
process.exit(1);
});
logger.info('Web API service startup initiated');

View file

@ -0,0 +1,688 @@
/**
* Exchange management routes
*/
import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger';
import { getPostgreSQLClient } from '@stock-bot/postgres-client';
import { getMongoDBClient } from '@stock-bot/mongodb-client';
const logger = getLogger('exchange-routes');
export const exchangeRoutes = new Hono();
// Get all exchanges with provider mapping counts
exchangeRoutes.get('/', async c => {
try {
const postgresClient = getPostgreSQLClient();
const query = `
SELECT
e.id,
e.code,
e.name,
e.country,
e.currency,
e.active,
e.created_at,
e.updated_at,
COUNT(pem.id) as provider_mapping_count,
COUNT(CASE WHEN pem.active = true THEN 1 END) as active_mapping_count,
COUNT(CASE WHEN pem.verified = true THEN 1 END) as verified_mapping_count,
STRING_AGG(DISTINCT pem.provider, ', ') as providers
FROM exchanges e
LEFT JOIN provider_exchange_mappings pem ON e.id = pem.master_exchange_id
GROUP BY e.id, e.code, e.name, e.country, e.currency, e.active, e.created_at, e.updated_at
ORDER BY e.code
`;
const result = await postgresClient.query(query);
return c.json({
success: true,
data: result.rows,
total: result.rows.length,
});
} catch (error) {
logger.error('Failed to get exchanges', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Get exchange by ID with detailed provider mappings
exchangeRoutes.get('/:id', async c => {
try {
const exchangeId = c.req.param('id');
const postgresClient = getPostgreSQLClient();
// Get exchange details
const exchangeQuery = 'SELECT * FROM exchanges WHERE id = $1';
const exchangeResult = await postgresClient.query(exchangeQuery, [exchangeId]);
if (exchangeResult.rows.length === 0) {
return c.json({ success: false, error: 'Exchange not found' }, 404);
}
// Get provider mappings for this exchange
const mappingsQuery = `
SELECT
pem.*,
e.code as master_exchange_code,
e.name as master_exchange_name
FROM provider_exchange_mappings pem
JOIN exchanges e ON pem.master_exchange_id = e.id
WHERE pem.master_exchange_id = $1
ORDER BY pem.provider, pem.provider_exchange_code
`;
const mappingsResult = await postgresClient.query(mappingsQuery, [exchangeId]);
return c.json({
success: true,
data: {
exchange: exchangeResult.rows[0],
provider_mappings: mappingsResult.rows,
},
});
} catch (error) {
logger.error('Failed to get exchange details', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Update exchange (activate/deactivate, rename, etc.)
exchangeRoutes.patch('/:id', async c => {
try {
const exchangeId = c.req.param('id');
const body = await c.req.json();
const postgresClient = getPostgreSQLClient();
const updateFields = [];
const values = [];
let paramIndex = 1;
// Build dynamic update query
if (body.active !== undefined) {
updateFields.push(`active = $${paramIndex++}`);
values.push(body.active);
}
if (body.name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(body.name);
}
if (body.country !== undefined) {
updateFields.push(`country = $${paramIndex++}`);
values.push(body.country);
}
if (body.currency !== undefined) {
updateFields.push(`currency = $${paramIndex++}`);
values.push(body.currency);
}
if (updateFields.length === 0) {
return c.json({ success: false, error: 'No valid fields to update' }, 400);
}
updateFields.push(`updated_at = NOW()`);
values.push(exchangeId);
const query = `
UPDATE exchanges
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await postgresClient.query(query, values);
if (result.rows.length === 0) {
return c.json({ success: false, error: 'Exchange not found' }, 404);
}
logger.info('Exchange updated', { exchangeId, updates: body });
return c.json({
success: true,
data: result.rows[0],
message: 'Exchange updated successfully',
});
} catch (error) {
logger.error('Failed to update exchange', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Get all provider mappings
exchangeRoutes.get('/provider-mappings/all', async c => {
try {
const postgresClient = getPostgreSQLClient();
const query = `
SELECT
pem.*,
e.code as master_exchange_code,
e.name as master_exchange_name,
e.active as master_exchange_active
FROM provider_exchange_mappings pem
JOIN exchanges e ON pem.master_exchange_id = e.id
ORDER BY pem.provider, pem.provider_exchange_code
`;
const result = await postgresClient.query(query);
return c.json({
success: true,
data: result.rows,
total: result.rows.length,
});
} catch (error) {
logger.error('Failed to get provider mappings', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Get provider mappings by provider
exchangeRoutes.get('/provider-mappings/:provider', async c => {
try {
const provider = c.req.param('provider');
const postgresClient = getPostgreSQLClient();
const query = `
SELECT
pem.*,
e.code as master_exchange_code,
e.name as master_exchange_name,
e.active as master_exchange_active
FROM provider_exchange_mappings pem
JOIN exchanges e ON pem.master_exchange_id = e.id
WHERE pem.provider = $1
ORDER BY pem.provider_exchange_code
`;
const result = await postgresClient.query(query, [provider]);
return c.json({
success: true,
data: result.rows,
total: result.rows.length,
provider,
});
} catch (error) {
logger.error('Failed to get provider mappings', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Update provider mapping (activate/deactivate, verify, change confidence)
exchangeRoutes.patch('/provider-mappings/:id', async c => {
try {
const mappingId = c.req.param('id');
const body = await c.req.json();
const postgresClient = getPostgreSQLClient();
const updateFields = [];
const values = [];
let paramIndex = 1;
// Build dynamic update query
if (body.active !== undefined) {
updateFields.push(`active = $${paramIndex++}`);
values.push(body.active);
}
if (body.verified !== undefined) {
updateFields.push(`verified = $${paramIndex++}`);
values.push(body.verified);
}
if (body.confidence !== undefined) {
updateFields.push(`confidence = $${paramIndex++}`);
values.push(body.confidence);
}
if (body.master_exchange_id !== undefined) {
updateFields.push(`master_exchange_id = $${paramIndex++}`);
values.push(body.master_exchange_id);
}
if (updateFields.length === 0) {
return c.json({ success: false, error: 'No valid fields to update' }, 400);
}
updateFields.push(`updated_at = NOW()`);
updateFields.push(`auto_mapped = false`); // Mark as manually managed
values.push(mappingId);
const query = `
UPDATE provider_exchange_mappings
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await postgresClient.query(query, values);
if (result.rows.length === 0) {
return c.json({ success: false, error: 'Provider mapping not found' }, 404);
}
logger.info('Provider mapping updated', { mappingId, updates: body });
return c.json({
success: true,
data: result.rows[0],
message: 'Provider mapping updated successfully',
});
} catch (error) {
logger.error('Failed to update provider mapping', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Create new provider mapping
exchangeRoutes.post('/provider-mappings', async c => {
try {
const body = await c.req.json();
const postgresClient = getPostgreSQLClient();
const {
provider,
provider_exchange_code,
provider_exchange_name,
master_exchange_id,
country_code,
currency,
confidence = 1.0,
active = false,
verified = false,
} = body;
if (!provider || !provider_exchange_code || !master_exchange_id) {
return c.json(
{
success: false,
error: 'Missing required fields: provider, provider_exchange_code, master_exchange_id',
},
400
);
}
const query = `
INSERT INTO provider_exchange_mappings
(provider, provider_exchange_code, provider_exchange_name, master_exchange_id,
country_code, currency, confidence, active, verified, auto_mapped)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false)
RETURNING *
`;
const result = await postgresClient.query(query, [
provider,
provider_exchange_code,
provider_exchange_name,
master_exchange_id,
country_code,
currency,
confidence,
active,
verified,
]);
logger.info('Provider mapping created', {
provider,
provider_exchange_code,
master_exchange_id,
});
return c.json(
{
success: true,
data: result.rows[0],
message: 'Provider mapping created successfully',
},
201
);
} catch (error) {
logger.error('Failed to create provider mapping', { error });
// Handle unique constraint violations
if (error instanceof Error && error.message.includes('duplicate key')) {
return c.json(
{
success: false,
error: 'Provider mapping already exists for this provider and exchange code',
},
409
);
}
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Get all available providers
exchangeRoutes.get('/providers/list', async c => {
try {
const postgresClient = getPostgreSQLClient();
const query = `
SELECT DISTINCT provider
FROM provider_exchange_mappings
ORDER BY provider
`;
const result = await postgresClient.query(query);
return c.json({
success: true,
data: result.rows.map(row => row.provider),
});
} catch (error) {
logger.error('Failed to get providers list', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Get all provider exchanges from MongoDB with mapping status
exchangeRoutes.get('/provider-exchanges/all', async c => {
try {
const postgresClient = getPostgreSQLClient();
const mongoClient = getMongoDBClient();
const db = mongoClient.getDatabase();
// Get all provider exchanges from different MongoDB collections
const [eodExchanges, ibExchanges, qmExchanges, unifiedExchanges] = await Promise.all([
db.collection('eodExchanges').find({ active: true }).toArray(),
db.collection('ibExchanges').find({}).toArray(),
db.collection('qmExchanges').find({}).toArray(),
db.collection('exchanges').find({}).toArray(),
]);
// Get existing mappings to mark which are already mapped
const existingMappingsQuery = `
SELECT provider, provider_exchange_code, master_exchange_id, e.code as master_exchange_code
FROM provider_exchange_mappings pem
JOIN exchanges e ON pem.master_exchange_id = e.id
`;
const existingMappings = await postgresClient.query(existingMappingsQuery);
const mappingLookup = new Map();
existingMappings.rows.forEach(row => {
mappingLookup.set(`${row.provider}:${row.provider_exchange_code}`, {
mapped: true,
master_exchange_id: row.master_exchange_id,
master_exchange_code: row.master_exchange_code,
});
});
// Combine all provider exchanges
const allProviderExchanges = [];
// EOD exchanges
eodExchanges.forEach(exchange => {
const key = `eod:${exchange.Code}`;
const mapping = mappingLookup.get(key);
allProviderExchanges.push({
provider: 'eod',
provider_exchange_code: exchange.Code,
provider_exchange_name: exchange.Name,
country_code: exchange.CountryISO2,
currency: exchange.Currency,
symbol_count: null,
is_mapped: !!mapping,
mapped_to_exchange_id: mapping?.master_exchange_id || null,
mapped_to_exchange_code: mapping?.master_exchange_code || null,
});
});
// IB exchanges
ibExchanges.forEach(exchange => {
const key = `ib:${exchange.exchange_id}`;
const mapping = mappingLookup.get(key);
allProviderExchanges.push({
provider: 'ib',
provider_exchange_code: exchange.exchange_id,
provider_exchange_name: exchange.name,
country_code: exchange.country_code,
currency: null,
symbol_count: null,
is_mapped: !!mapping,
mapped_to_exchange_id: mapping?.master_exchange_id || null,
mapped_to_exchange_code: mapping?.master_exchange_code || null,
});
});
// QM exchanges
qmExchanges.forEach(exchange => {
const key = `qm:${exchange.exchangeCode}`;
const mapping = mappingLookup.get(key);
allProviderExchanges.push({
provider: 'qm',
provider_exchange_code: exchange.exchangeCode,
provider_exchange_name: exchange.name,
country_code: exchange.countryCode,
currency: exchange.countryCode === 'CA' ? 'CAD' : 'USD',
symbol_count: null,
is_mapped: !!mapping,
mapped_to_exchange_id: mapping?.master_exchange_id || null,
mapped_to_exchange_code: mapping?.master_exchange_code || null,
});
});
// Unified exchanges
unifiedExchanges.forEach(exchange => {
const key = `unified:${exchange.sourceCode || exchange.code}`;
const mapping = mappingLookup.get(key);
allProviderExchanges.push({
provider: 'unified',
provider_exchange_code: exchange.sourceCode || exchange.code,
provider_exchange_name: exchange.sourceName || exchange.name,
country_code: null,
currency: null,
symbol_count: null,
is_mapped: !!mapping,
mapped_to_exchange_id: mapping?.master_exchange_id || null,
mapped_to_exchange_code: mapping?.master_exchange_code || null,
});
});
return c.json({
success: true,
data: allProviderExchanges,
total: allProviderExchanges.length,
});
} catch (error) {
logger.error('Failed to get provider exchanges', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Get unmapped provider exchanges by provider
exchangeRoutes.get('/provider-exchanges/unmapped/:provider', async c => {
try {
const provider = c.req.param('provider');
const postgresClient = getPostgreSQLClient();
const mongoClient = getMongoDBClient();
const db = mongoClient.getDatabase();
// Get existing mappings for this provider
const existingMappingsQuery = `
SELECT provider_exchange_code
FROM provider_exchange_mappings
WHERE provider = $1
`;
const existingMappings = await postgresClient.query(existingMappingsQuery, [provider]);
const mappedCodes = new Set(existingMappings.rows.map(row => row.provider_exchange_code));
let providerExchanges = [];
switch (provider) {
case 'eod':
const eodExchanges = await db.collection('eodExchanges').find({ active: true }).toArray();
providerExchanges = eodExchanges
.filter(exchange => !mappedCodes.has(exchange.Code))
.map(exchange => ({
provider_exchange_code: exchange.Code,
provider_exchange_name: exchange.Name,
country_code: exchange.CountryISO2,
currency: exchange.Currency,
symbol_count: null,
}));
break;
case 'ib':
const ibExchanges = await db.collection('ibExchanges').find({}).toArray();
providerExchanges = ibExchanges
.filter(exchange => !mappedCodes.has(exchange.exchange_id))
.map(exchange => ({
provider_exchange_code: exchange.exchange_id,
provider_exchange_name: exchange.name,
country_code: exchange.country_code,
currency: null,
symbol_count: null,
}));
break;
case 'qm':
const qmExchanges = await db.collection('qmExchanges').find({}).toArray();
providerExchanges = qmExchanges
.filter(exchange => !mappedCodes.has(exchange.exchangeCode))
.map(exchange => ({
provider_exchange_code: exchange.exchangeCode,
provider_exchange_name: exchange.name,
country_code: exchange.countryCode,
currency: exchange.countryCode === 'CA' ? 'CAD' : 'USD',
symbol_count: null,
}));
break;
case 'unified':
const unifiedExchanges = await db.collection('exchanges').find({}).toArray();
providerExchanges = unifiedExchanges
.filter(exchange => !mappedCodes.has(exchange.sourceCode || exchange.code))
.map(exchange => ({
provider_exchange_code: exchange.sourceCode || exchange.code,
provider_exchange_name: exchange.sourceName || exchange.name,
country_code: null,
currency: null,
symbol_count: null,
}));
break;
default:
return c.json(
{
success: false,
error: `Unknown provider: ${provider}`,
},
400
);
}
return c.json({
success: true,
data: providerExchanges,
total: providerExchanges.length,
provider,
});
} catch (error) {
logger.error('Failed to get unmapped provider exchanges', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
// Get exchange statistics
exchangeRoutes.get('/stats/summary', async c => {
try {
const postgresClient = getPostgreSQLClient();
const query = `
SELECT
(SELECT COUNT(*) FROM exchanges) as total_exchanges,
(SELECT COUNT(*) FROM exchanges WHERE active = true) as active_exchanges,
(SELECT COUNT(DISTINCT country) FROM exchanges) as countries,
(SELECT COUNT(DISTINCT currency) FROM exchanges) as currencies,
(SELECT COUNT(*) FROM provider_exchange_mappings) as total_provider_mappings,
(SELECT COUNT(*) FROM provider_exchange_mappings WHERE active = true) as active_provider_mappings,
(SELECT COUNT(*) FROM provider_exchange_mappings WHERE verified = true) as verified_provider_mappings,
(SELECT COUNT(DISTINCT provider) FROM provider_exchange_mappings) as providers
`;
const result = await postgresClient.query(query);
return c.json({
success: true,
data: result.rows[0],
});
} catch (error) {
logger.error('Failed to get exchange statistics', { error });
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});

View file

@ -0,0 +1,69 @@
/**
* Health check routes
*/
import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger';
import { getMongoDBClient } from '@stock-bot/mongodb-client';
import { getPostgreSQLClient } from '@stock-bot/postgres-client';
const logger = getLogger('health-routes');
export const healthRoutes = new Hono();
// Basic health check
healthRoutes.get('/', c => {
return c.json({
status: 'healthy',
service: 'web-api',
timestamp: new Date().toISOString(),
});
});
// Detailed health check with database connectivity
healthRoutes.get('/detailed', async c => {
const health = {
status: 'healthy',
service: 'web-api',
timestamp: new Date().toISOString(),
checks: {
mongodb: { status: 'unknown', message: '' },
postgresql: { status: 'unknown', message: '' },
},
};
// Check MongoDB
try {
const mongoClient = getMongoDBClient();
if (mongoClient.connected) {
// Try a simple operation
const db = mongoClient.getDatabase();
await db.admin().ping();
health.checks.mongodb = { status: 'healthy', message: 'Connected and responsive' };
} else {
health.checks.mongodb = { status: 'unhealthy', message: 'Not connected' };
}
} catch (error) {
health.checks.mongodb = {
status: 'unhealthy',
message: error instanceof Error ? error.message : 'Unknown error',
};
}
// Check PostgreSQL
try {
const postgresClient = getPostgreSQLClient();
await postgresClient.query('SELECT 1');
health.checks.postgresql = { status: 'healthy', message: 'Connected and responsive' };
} catch (error) {
health.checks.postgresql = {
status: 'unhealthy',
message: error instanceof Error ? error.message : 'Unknown error',
};
}
// Overall status
const allHealthy = Object.values(health.checks).every(check => check.status === 'healthy');
health.status = allHealthy ? 'healthy' : 'unhealthy';
const statusCode = allHealthy ? 200 : 503;
return c.json(health, statusCode);
});

View file

@ -0,0 +1,5 @@
/**
* Routes index - exports all route modules
*/
export { exchangeRoutes } from './exchange.routes';
export { healthRoutes } from './health.routes';