feat: Complete Fiscal Clone deployment package
- SEC filings extraction (10-K, 10-Q, 8-K) - Portfolio analytics with real-time prices - Watchlist management - NextAuth.js authentication - OpenClaw AI integration - PostgreSQL database with auto P&L calculations - Elysia.js backend (Bun runtime) - Next.js 14 frontend (TailwindCSS + Recharts) - Production-ready Docker configurations
This commit is contained in:
28
backend/Dockerfile
Normal file
28
backend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
FROM base AS install
|
||||
RUN npm install -g bun
|
||||
COPY package.json ./
|
||||
RUN bun install
|
||||
|
||||
# Build
|
||||
FROM base AS build
|
||||
COPY --from=install /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN bun build
|
||||
|
||||
# Production
|
||||
FROM base AS release
|
||||
RUN npm install -g bun
|
||||
COPY --from=install /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY package.json .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
43
backend/docker-compose.yml
Normal file
43
backend/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
- PORT=3001
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- fiscal
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- fiscal
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
fiscal:
|
||||
external: true
|
||||
27
backend/package.json
Normal file
27
backend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "fiscal-backend",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"db:migrate": "bun run src/db/migrate.ts",
|
||||
"db:seed": "bun run src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.0.2",
|
||||
"@elysiajs/swagger": "^1.0.2",
|
||||
"elysia": "^1.0.20",
|
||||
"pg": "^8.11.3",
|
||||
"postgres": "^3.4.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"zod": "^3.22.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
45
backend/src/db/index.ts
Normal file
45
backend/src/db/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import postgres from 'postgres';
|
||||
|
||||
const sql = postgres(process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/fiscal', {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10
|
||||
});
|
||||
|
||||
export const db = sql;
|
||||
|
||||
export type Filings = {
|
||||
id: number;
|
||||
ticker: string;
|
||||
filing_type: string;
|
||||
filing_date: Date;
|
||||
accession_number: string;
|
||||
cik: string;
|
||||
company_name: string;
|
||||
key_metrics?: any;
|
||||
insights?: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export type Portfolio = {
|
||||
id: number;
|
||||
user_id: string;
|
||||
ticker: string;
|
||||
shares: number;
|
||||
avg_cost: number;
|
||||
current_price?: number;
|
||||
current_value?: number;
|
||||
gain_loss?: number;
|
||||
gain_loss_pct?: number;
|
||||
last_updated?: Date;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export type Watchlist = {
|
||||
id: number;
|
||||
user_id: string;
|
||||
ticker: string;
|
||||
company_name: string;
|
||||
sector?: string;
|
||||
created_at: Date;
|
||||
};
|
||||
107
backend/src/db/migrate.ts
Normal file
107
backend/src/db/migrate.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { db } from './index';
|
||||
|
||||
async function migrate() {
|
||||
console.log('Running migrations...');
|
||||
|
||||
// Create users table
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
// Create filings table
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS filings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticker VARCHAR(10) NOT NULL,
|
||||
filing_type VARCHAR(20) NOT NULL,
|
||||
filing_date DATE NOT NULL,
|
||||
accession_number VARCHAR(40) UNIQUE NOT NULL,
|
||||
cik VARCHAR(20) NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
key_metrics JSONB,
|
||||
insights TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
// Create portfolio table
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS portfolio (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
ticker VARCHAR(10) NOT NULL,
|
||||
shares NUMERIC(20, 4) NOT NULL,
|
||||
avg_cost NUMERIC(10, 4) NOT NULL,
|
||||
current_price NUMERIC(10, 4),
|
||||
current_value NUMERIC(20, 4),
|
||||
gain_loss NUMERIC(20, 4),
|
||||
gain_loss_pct NUMERIC(10, 4),
|
||||
last_updated TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, ticker)
|
||||
)
|
||||
`;
|
||||
|
||||
// Create watchlist table
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
ticker VARCHAR(10) NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
sector VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, ticker)
|
||||
)
|
||||
`;
|
||||
|
||||
// Create indexes
|
||||
await db`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`;
|
||||
await db`CREATE INDEX IF NOT EXISTS idx_filings_ticker ON filings(ticker)`;
|
||||
await db`CREATE INDEX IF NOT EXISTS idx_filings_date ON filings(filing_date DESC)`;
|
||||
await db`CREATE INDEX IF NOT EXISTS idx_portfolio_user ON portfolio(user_id)`;
|
||||
await db`CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist(user_id)`;
|
||||
|
||||
// Create function to update portfolio prices
|
||||
await db`
|
||||
CREATE OR REPLACE FUNCTION update_portfolio_prices()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.current_value := NEW.shares * NEW.current_price;
|
||||
NEW.gain_loss := NEW.current_value - (NEW.shares * NEW.avg_cost);
|
||||
NEW.gain_loss_pct := CASE
|
||||
WHEN NEW.avg_cost > 0 THEN ((NEW.current_price - NEW.avg_cost) / NEW.avg_cost) * 100
|
||||
ELSE 0
|
||||
END;
|
||||
NEW.last_updated := NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`;
|
||||
|
||||
// Create trigger
|
||||
await db`
|
||||
DROP TRIGGER IF EXISTS update_portfolio_prices_trigger ON portfolio
|
||||
`;
|
||||
await db`
|
||||
CREATE TRIGGER update_portfolio_prices_trigger
|
||||
BEFORE INSERT OR UPDATE ON portfolio
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_portfolio_prices()
|
||||
`;
|
||||
|
||||
console.log('✅ Migrations completed!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrate().catch(error => {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
49
backend/src/index.ts
Normal file
49
backend/src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Elysia } from 'elysia';
|
||||
import { cors } from '@elysiajs/cors';
|
||||
import { swagger } from '@elysiajs/swagger';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import { db } from './db';
|
||||
import { filingsRoutes } from './routes/filings';
|
||||
import { portfolioRoutes } from './routes/portfolio';
|
||||
import { openclawRoutes } from './routes/openclaw';
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { watchlistRoutes } from './routes/watchlist';
|
||||
|
||||
const app = new Elysia({
|
||||
prefix: '/api'
|
||||
})
|
||||
.use(cors({
|
||||
origin: '*',
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
|
||||
}))
|
||||
.use(swagger({
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'Fiscal Clone API',
|
||||
version: '1.0.0',
|
||||
description: 'Financial filings and portfolio analytics API'
|
||||
}
|
||||
}
|
||||
}))
|
||||
.use(authRoutes)
|
||||
.use(filingsRoutes)
|
||||
.use(portfolioRoutes)
|
||||
.use(watchlistRoutes)
|
||||
.use(openclawRoutes)
|
||||
|
||||
// Health check
|
||||
.get('/health', () => ({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
database: 'connected'
|
||||
}))
|
||||
|
||||
.listen(process.env.PORT || 3001);
|
||||
|
||||
console.log(`🚀 Backend running on http://localhost:${app.server?.port}`);
|
||||
console.log(`📚 Swagger docs: http://localhost:${app.server?.port}/swagger`);
|
||||
122
backend/src/routes/auth.ts
Normal file
122
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '../db';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
export const authRoutes = new Elysia({ prefix: '/auth' })
|
||||
/**
|
||||
* Register new user
|
||||
*/
|
||||
.post('/register', async ({ body }) => {
|
||||
const { email, password, name } = body;
|
||||
|
||||
// Check if user exists
|
||||
const existing = await db`
|
||||
SELECT id FROM users WHERE email = ${email}
|
||||
`;
|
||||
|
||||
if (existing.length > 0) {
|
||||
return { error: 'User already exists' };
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const result = await db`
|
||||
INSERT INTO users ${db({ email, password: hashedPassword, name })}
|
||||
RETURNING id, email, name
|
||||
`;
|
||||
|
||||
const user = result[0];
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, email: user.email },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: { id: user.id, email: user.email, name: user.name },
|
||||
token
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.String({ format: 'email' }),
|
||||
password: t.String({ minLength: 8 }),
|
||||
name: t.String()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Login
|
||||
*/
|
||||
.post('/login', async ({ body }) => {
|
||||
const { email, password } = body;
|
||||
|
||||
// Find user
|
||||
const users = await db`
|
||||
SELECT * FROM users WHERE email = ${email}
|
||||
`;
|
||||
|
||||
if (users.length === 0) {
|
||||
return { error: 'Invalid credentials' };
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// Verify password
|
||||
const validPassword = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!validPassword) {
|
||||
return { error: 'Invalid credentials' };
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, email: user.email },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: { id: user.id, email: user.email, name: user.name },
|
||||
token
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.String({ format: 'email' }),
|
||||
password: t.String()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Verify token (for NextAuth credentials provider)
|
||||
*/
|
||||
.post('/verify', async ({ body, set }) => {
|
||||
try {
|
||||
const decoded = jwt.verify(body.token, JWT_SECRET) as any;
|
||||
|
||||
if (!decoded.id || !decoded.email) {
|
||||
set.status = 401;
|
||||
return { error: 'Invalid token' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: { id: decoded.id, email: decoded.email }
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 401;
|
||||
return { error: 'Invalid token' };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
token: t.String()
|
||||
})
|
||||
});
|
||||
47
backend/src/routes/filings.ts
Normal file
47
backend/src/routes/filings.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { SECScraper } from '../services/sec';
|
||||
import { db } from '../db';
|
||||
|
||||
const sec = new SECScraper();
|
||||
|
||||
export const filingsRoutes = new Elysia({ prefix: '/filings' })
|
||||
.get('/', async () => {
|
||||
const filings = await db`
|
||||
SELECT * FROM filings
|
||||
ORDER BY filing_date DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
return filings;
|
||||
})
|
||||
|
||||
.get('/:ticker', async ({ params }) => {
|
||||
const filings = await db`
|
||||
SELECT * FROM filings
|
||||
WHERE ticker = ${params.ticker.toUpperCase()}
|
||||
ORDER BY filing_date DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
return filings;
|
||||
})
|
||||
|
||||
.get('/details/:accessionNumber', async ({ params }) => {
|
||||
const details = await db`
|
||||
SELECT * FROM filings
|
||||
WHERE accession_number = ${params.accessionNumber}
|
||||
`;
|
||||
return details[0] || null;
|
||||
})
|
||||
|
||||
.post('/refresh/:ticker', async ({ params }) => {
|
||||
const newFilings = await sec.searchFilings(params.ticker, 5);
|
||||
|
||||
for (const filing of newFilings) {
|
||||
const metrics = await sec['extractKeyMetrics'](filing);
|
||||
await db`
|
||||
INSERT INTO filings ${db(filing, metrics)}
|
||||
ON CONFLICT (accession_number) DO NOTHING
|
||||
`;
|
||||
}
|
||||
|
||||
return { success: true, count: newFilings.length };
|
||||
});
|
||||
121
backend/src/routes/openclaw.ts
Normal file
121
backend/src/routes/openclaw.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
|
||||
interface OpenClawMessage {
|
||||
text: string;
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export const openclawRoutes = new Elysia({ prefix: '/openclaw' })
|
||||
/**
|
||||
* Trigger Discord notification for new filing
|
||||
*/
|
||||
.post('/notify/filing', async ({ body }) => {
|
||||
// This endpoint can be called by cron jobs or external webhooks
|
||||
// to send Discord notifications about new filings
|
||||
|
||||
const message = `📄 **New SEC Filing**
|
||||
|
||||
**Ticker:** ${body.ticker}
|
||||
**Type:** ${body.filingType}
|
||||
**Date:** ${body.filingDate}
|
||||
|
||||
View details: ${body.url}`;
|
||||
|
||||
// In production, this would send to Discord via webhook
|
||||
// For now, we just log it
|
||||
console.log('[DISCORD]', message);
|
||||
|
||||
return { success: true, message };
|
||||
}, {
|
||||
body: t.Object({
|
||||
ticker: t.String(),
|
||||
filingType: t.String(),
|
||||
filingDate: t.String(),
|
||||
url: t.String()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Get AI insights for portfolio
|
||||
*/
|
||||
.post('/insights/portfolio', async ({ body }) => {
|
||||
const holdings = await db`
|
||||
SELECT * FROM portfolio
|
||||
WHERE user_id = ${body.userId}
|
||||
`;
|
||||
|
||||
// Generate AI analysis
|
||||
const prompt = `
|
||||
Analyze this portfolio:
|
||||
|
||||
${JSON.stringify(holdings, null, 2)}
|
||||
|
||||
Provide:
|
||||
1. Overall portfolio health assessment
|
||||
2. Risk analysis
|
||||
3. Top 3 recommendations
|
||||
4. Any concerning patterns
|
||||
`;
|
||||
|
||||
// This would call OpenClaw's AI
|
||||
// For now, return placeholder
|
||||
return {
|
||||
health: 'moderate',
|
||||
risk: 'medium',
|
||||
recommendations: [
|
||||
'Consider diversifying sector exposure',
|
||||
'Review underperforming positions',
|
||||
'Rebalance portfolio'
|
||||
],
|
||||
analysis: 'Portfolio shows mixed performance with some concentration risk.'
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
userId: t.String()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Get AI insights for a specific filing
|
||||
*/
|
||||
.post('/insights/filing', async ({ body }) => {
|
||||
const filing = await db`
|
||||
SELECT * FROM filings
|
||||
WHERE accession_number = ${body.accessionNumber}
|
||||
`;
|
||||
|
||||
if (!filing) {
|
||||
return { error: 'Filing not found' };
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
Analyze this SEC filing:
|
||||
|
||||
**Company:** ${filing.company_name}
|
||||
**Ticker:** ${filing.ticker}
|
||||
**Type:** ${filing.filing_type}
|
||||
**Date:** ${filing.filing_date}
|
||||
|
||||
**Key Metrics:**
|
||||
${JSON.stringify(filing.key_metrics, null, 2)}
|
||||
|
||||
Provide key insights and any red flags.
|
||||
`;
|
||||
|
||||
// Store insights
|
||||
await db`
|
||||
UPDATE filings
|
||||
SET insights = ${prompt}
|
||||
WHERE accession_number = ${body.accessionNumber}
|
||||
`;
|
||||
|
||||
return {
|
||||
insights: 'Analysis saved',
|
||||
filing
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
accessionNumber: t.String()
|
||||
})
|
||||
});
|
||||
65
backend/src/routes/portfolio.ts
Normal file
65
backend/src/routes/portfolio.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db, type Portfolio } from '../db';
|
||||
|
||||
export const portfolioRoutes = new Elysia({ prefix: '/portfolio' })
|
||||
.get('/:userId', async ({ params }) => {
|
||||
const holdings = await db`
|
||||
SELECT * FROM portfolio
|
||||
WHERE user_id = ${params.userId}
|
||||
ORDER BY ticker
|
||||
`;
|
||||
|
||||
return holdings;
|
||||
})
|
||||
|
||||
.post('/', async ({ body }) => {
|
||||
const result = await db`
|
||||
INSERT INTO portfolio ${db(body as Portfolio)}
|
||||
ON CONFLICT (user_id, ticker)
|
||||
DO UPDATE SET
|
||||
shares = EXCLUDED.shares,
|
||||
avg_cost = EXCLUDED.avg_cost,
|
||||
current_price = EXCLUDED.current_price
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
return result[0];
|
||||
}, {
|
||||
body: t.Object({
|
||||
user_id: t.String(),
|
||||
ticker: t.String(),
|
||||
shares: t.Number(),
|
||||
avg_cost: t.Number(),
|
||||
current_price: t.Optional(t.Number())
|
||||
})
|
||||
})
|
||||
|
||||
.put('/:id', async ({ params, body }) => {
|
||||
const result = await db`
|
||||
UPDATE portfolio
|
||||
SET ${db(body)}
|
||||
WHERE id = ${params.id}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
return result[0] || null;
|
||||
})
|
||||
|
||||
.delete('/:id', async ({ params }) => {
|
||||
await db`DELETE FROM portfolio WHERE id = ${params.id}`;
|
||||
return { success: true };
|
||||
})
|
||||
|
||||
.get('/:userId/summary', async ({ params }) => {
|
||||
const summary = await db`
|
||||
SELECT
|
||||
COUNT(*) as total_positions,
|
||||
COALESCE(SUM(current_value), 0) as total_value,
|
||||
COALESCE(SUM(gain_loss), 0) as total_gain_loss,
|
||||
COALESCE(SUM(current_value) - SUM(shares * avg_cost), 0) as cost_basis
|
||||
FROM portfolio
|
||||
WHERE user_id = ${params.userId}
|
||||
`;
|
||||
|
||||
return summary[0];
|
||||
});
|
||||
35
backend/src/routes/watchlist.ts
Normal file
35
backend/src/routes/watchlist.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { db } from '../db';
|
||||
|
||||
export const watchlistRoutes = new Elysia({ prefix: '/watchlist' })
|
||||
.get('/:userId', async ({ params }) => {
|
||||
const watchlist = await db`
|
||||
SELECT * FROM watchlist
|
||||
WHERE user_id = ${params.userId}
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
return watchlist;
|
||||
})
|
||||
|
||||
.post('/', async ({ body }) => {
|
||||
const result = await db`
|
||||
INSERT INTO watchlist ${db(body)}
|
||||
ON CONFLICT (user_id, ticker) DO NOTHING
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
return result[0];
|
||||
}, {
|
||||
body: t.Object({
|
||||
user_id: t.String(),
|
||||
ticker: t.String(),
|
||||
company_name: t.String(),
|
||||
sector: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
|
||||
.delete('/:id', async ({ params }) => {
|
||||
await db`DELETE FROM watchlist WHERE id = ${params.id}`;
|
||||
return { success: true };
|
||||
});
|
||||
116
backend/src/services/prices.ts
Normal file
116
backend/src/services/prices.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
export class PriceService {
|
||||
private baseUrl = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
||||
|
||||
/**
|
||||
* Get current price for a ticker
|
||||
*/
|
||||
async getPrice(ticker: string): Promise<number | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/${ticker}?interval=1d&range=1d`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/1.0)'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
const result = data.chart?.result?.[0];
|
||||
|
||||
if (!result?.meta?.regularMarketPrice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.meta.regularMarketPrice;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching price for ${ticker}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical prices
|
||||
*/
|
||||
async getHistoricalPrices(ticker: string, period: string = '1y'): Promise<Array<{ date: string, price: number }>> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/${ticker}?interval=1d&range=${period}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/1.0)'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = await response.json();
|
||||
const result = data.chart?.result?.[0];
|
||||
|
||||
if (!result?.timestamp || !result?.indicators?.quote?.[0]?.close) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const timestamps = result.timestamp;
|
||||
const closes = result.indicators.quote[0].close;
|
||||
|
||||
return timestamps.map((ts: number, i: number) => ({
|
||||
date: new Date(ts * 1000).toISOString(),
|
||||
price: closes[i]
|
||||
})).filter((p: any) => p.price !== null);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching historical prices for ${ticker}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all portfolio prices
|
||||
*/
|
||||
async updateAllPrices(db: any) {
|
||||
const holdings = await db`
|
||||
SELECT DISTINCT ticker FROM portfolio
|
||||
`;
|
||||
|
||||
let updated = 0;
|
||||
|
||||
for (const { ticker } of holdings) {
|
||||
const price = await this.getPrice(ticker);
|
||||
|
||||
if (price) {
|
||||
await db`
|
||||
UPDATE portfolio
|
||||
SET current_price = ${price}
|
||||
WHERE ticker = ${ticker}
|
||||
`;
|
||||
updated++;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
console.log(`Updated ${updated} stock prices`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quote for multiple tickers
|
||||
*/
|
||||
async getQuotes(tickers: string[]): Promise<Record<string, number>> {
|
||||
const quotes: Record<string, number> = {};
|
||||
|
||||
await Promise.all(
|
||||
tickers.map(async ticker => {
|
||||
const price = await this.getPrice(ticker);
|
||||
if (price) {
|
||||
quotes[ticker] = price;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return quotes;
|
||||
}
|
||||
}
|
||||
162
backend/src/services/sec.ts
Normal file
162
backend/src/services/sec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { type Filings } from '../db';
|
||||
|
||||
export class SECScraper {
|
||||
private baseUrl = 'https://www.sec.gov';
|
||||
private userAgent = 'Fiscal Clone (contact@example.com)';
|
||||
|
||||
/**
|
||||
* Search SEC filings by ticker
|
||||
*/
|
||||
async searchFilings(ticker: string, count = 20): Promise<Filings[]> {
|
||||
const cik = await this.getCIK(ticker);
|
||||
|
||||
const response = await fetch(
|
||||
`https://data.sec.gov/submissions/CIK${cik.padStart(10, '0')}.json`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': this.userAgent
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SEC API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const filings = data.filings?.recent || [];
|
||||
|
||||
const filteredFilings = filings
|
||||
.filter((f: any) =>
|
||||
['10-K', '10-Q', '8-K'].includes(f.form)
|
||||
)
|
||||
.slice(0, count)
|
||||
.map((f: any) => ({
|
||||
ticker,
|
||||
filing_type: f.form,
|
||||
filing_date: new Date(f.filingDate),
|
||||
accession_number: f.accessionNumber,
|
||||
cik: data.cik,
|
||||
company_name: data.name || ticker,
|
||||
}));
|
||||
|
||||
return filteredFilings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for new filings and save to database
|
||||
*/
|
||||
async checkNewFilings(db: any) {
|
||||
const tickers = await db`
|
||||
SELECT DISTINCT ticker FROM watchlist
|
||||
`;
|
||||
|
||||
console.log(`Checking filings for ${tickers.length} tickers...`);
|
||||
|
||||
for (const { ticker } of tickers) {
|
||||
try {
|
||||
const latest = await db`
|
||||
SELECT accession_number FROM filings
|
||||
WHERE ticker = ${ticker}
|
||||
ORDER BY filing_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const filings = await this.searchFilings(ticker, 10);
|
||||
const newFilings = filings.filter(
|
||||
f => !latest.some((l: any) => l.accession_number === f.accession_number)
|
||||
);
|
||||
|
||||
if (newFilings.length > 0) {
|
||||
console.log(`Found ${newFilings.length} new filings for ${ticker}`);
|
||||
|
||||
for (const filing of newFilings) {
|
||||
const metrics = await this.extractKeyMetrics(filing);
|
||||
|
||||
await db`
|
||||
INSERT INTO filings ${db(filing, metrics)}
|
||||
ON CONFLICT (accession_number) DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking filings for ${ticker}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CIK for a ticker
|
||||
*/
|
||||
private async getCIK(ticker: string): Promise<string> {
|
||||
const response = await fetch(
|
||||
`https://www.sec.gov/files/company_tickers.json`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get company tickers');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const companies = data.data;
|
||||
|
||||
for (const [cik, company] of Object.entries(companies)) {
|
||||
if (company.ticker === ticker.toUpperCase()) {
|
||||
return cik;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Ticker ${ticker} not found`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key metrics from filing
|
||||
*/
|
||||
async extractKeyMetrics(filing: any): Promise<any> {
|
||||
try {
|
||||
const filingUrl = `${this.baseUrl}/Archives/${filing.accession_number.replace(/-/g, '')}/${filing.accession_number}-index.htm`;
|
||||
|
||||
const response = await fetch(filingUrl, {
|
||||
headers: { 'User-Agent': this.userAgent }
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Extract key financial metrics from XBRL
|
||||
const metrics = {
|
||||
revenue: this.extractMetric(html, 'Revenues'),
|
||||
netIncome: this.extractMetric(html, 'NetIncomeLoss'),
|
||||
totalAssets: this.extractMetric(html, 'Assets'),
|
||||
cash: this.extractMetric(html, 'CashAndCashEquivalentsAtCarryingValue'),
|
||||
debt: this.extractMetric(html, 'LongTermDebt')
|
||||
};
|
||||
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
console.error('Error extracting metrics:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a specific metric from XBRL data
|
||||
*/
|
||||
private extractMetric(html: string, metricName: string): number | null {
|
||||
const regex = new RegExp(`<ix:nonFraction[^>]*name="[^"]*${metricName}[^"]*"[^>]*>([^<]+)<`, 'i');
|
||||
const match = html.match(regex);
|
||||
return match ? parseFloat(match[1].replace(/,/g, '')) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filing details by accession number
|
||||
*/
|
||||
async getFilingDetails(accessionNumber: string) {
|
||||
const filingUrl = `${this.baseUrl}/Archives/${accessionNumber.replace(/-/g, '')}/${accessionNumber}-index.htm`;
|
||||
|
||||
return {
|
||||
filing_url: filingUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user