From de0b615c5344b4bf1ba03d6a08a4bfa4fd016793 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 29 May 2026 13:16:58 +0800 Subject: [PATCH 01/11] feat(paper-trading): add paper trading feature and related scripts Added a complete paper trading system, including account management and trade execution functionalities. Introduced multiple helper scripts for portfolio analysis, stock research, and automated trade execution. Configured dependency build options to optimize performance. - Added paper trading page and dashboard components - Implemented AI-driven automated simulated trading functionality - Added MongoDB data models for trade record management - Implemented automatic price fetching and caching mechanisms - Added portfolio analysis and risk monitoring features - Configured npm dependency build optimizations --- .npmrc | 3 + _scripts/check_positions.js | 96 + _scripts/execute_trades.js | 112 + _scripts/research_candidates.js | 80 + _scripts/round2_trades.js | 104 + _scripts/round3_trades.js | 80 + _scripts/sector_analysis.js | 67 + app/(root)/paper-trading/page.tsx | 38 + components/paper-trading/AITradingPanel.tsx | 431 + components/paper-trading/AccountSwitcher.tsx | 167 + .../paper-trading/PaperTradingDashboard.tsx | 464 + database/models/paper-trading.model.ts | 123 + lib/actions/ai-trading.actions.ts | 287 + lib/actions/finnhub.actions.ts | 105 +- lib/actions/paper-trading.actions.ts | 506 ++ lib/constants.ts | 1 + package.json | 3 +- pnpm-lock.yaml | 8051 +++++++++++++++++ pnpm-workspace.yaml | 6 + 19 files changed, 10713 insertions(+), 11 deletions(-) create mode 100644 .npmrc create mode 100644 _scripts/check_positions.js create mode 100644 _scripts/execute_trades.js create mode 100644 _scripts/research_candidates.js create mode 100644 _scripts/round2_trades.js create mode 100644 _scripts/round3_trades.js create mode 100644 _scripts/sector_analysis.js create mode 100644 app/(root)/paper-trading/page.tsx create mode 100644 components/paper-trading/AITradingPanel.tsx create mode 100644 components/paper-trading/AccountSwitcher.tsx create mode 100644 components/paper-trading/PaperTradingDashboard.tsx create mode 100644 database/models/paper-trading.model.ts create mode 100644 lib/actions/ai-trading.actions.ts create mode 100644 lib/actions/paper-trading.actions.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..b338d7a6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +onlyBuiltDependencies[]=protobufjs +onlyBuiltDependencies[]=sharp +onlyBuiltDependencies[]=unrs-resolver diff --git a/_scripts/check_positions.js b/_scripts/check_positions.js new file mode 100644 index 00000000..0ca5453e --- /dev/null +++ b/_scripts/check_positions.js @@ -0,0 +1,96 @@ +const { MongoClient } = require('mongodb'); +const undici = require('undici'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const USER_ID = '6a17b7387348b73526f6a170'; +const PROXY = 'http://127.0.0.1:7890'; +const dispatcher = new undici.ProxyAgent(PROXY); +const fetch = (url) => undici.fetch(url, { dispatcher }); +const token = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8').match(/NEXT_PUBLIC_FINNHUB_API_KEY=(\S+)/)?.[1] || ''; +const cacheDir = path.join(process.env.HOME, '.hermes', 'cache', 'finnhub'); +fs.mkdirSync(cacheDir, { recursive: true }); + +async function getQuote(sym) { + const hash = crypto.createHash('md5').update('quote_' + sym + '_' + token).digest('hex'); + const cf = path.join(cacheDir, hash + '.json'); + try { + const c = JSON.parse(fs.readFileSync(cf, 'utf8')); + if (Date.now() - c.ts < 60 * 1000) return c.data; + } catch {} + const r = await fetch('https://finnhub.io/api/v1/quote?symbol=' + sym + '&token=' + token); + const d = await r.json(); + fs.writeFileSync(cf, JSON.stringify({ ts: Date.now(), data: d })); + return d; +} + +async function main() { + const client = new MongoClient('mongodb://localhost:27017'); + await client.connect(); + const db = client.db('openstock'); + const accounts = db.collection('paperaccounts'); + const trades = db.collection('papertrades'); + + const acc = await accounts.findOne({ userId: USER_ID }); + if (!acc) { console.log('No account found'); await client.close(); return; } + + const openPositions = await trades.aggregate([ + { $match: { userId: USER_ID } }, + { $group: { _id: '$symbol', shares: { $sum: { $cond: [{ $eq: ['$type', 'BUY'] }, '$shares', { $multiply: ['$shares', -1] }] } }, totalCost: { $sum: { $cond: [{ $eq: ['$type', 'BUY'] }, '$total', 0] } }, company: { $first: '$company' } } }, + { $match: { shares: { $gt: 0 } } } + ]).toArray(); + + console.log('=== PAPER TRADING PORTFOLIO ==='); + console.log('Date:', new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })); + console.log(''); + + let totalMV = 0; + let totalPL = 0; + const positionLines = []; + + for (const pos of openPositions) { + const q = await getQuote(pos._id); + const price = q?.c || 0; + const avgCost = pos.totalCost / pos.shares; + const marketValue = +(pos.shares * price).toFixed(2); + const pl = +(marketValue - pos.totalCost).toFixed(2); + const plPct = pos.totalCost > 0 ? +((pl / pos.totalCost) * 100).toFixed(2) : 0; + + totalMV += marketValue; + totalPL += pl; + + const emoji = pl >= 0 ? '🟢' : '🔴'; + const warn = plPct <= -10 ? ' ⚠️ STOP LOSS' : plPct <= -7 ? ' ⚠️ Watch' : ''; + positionLines.push(`${emoji} ${pos._id.padEnd(6)} ${(pos.company || '').padEnd(25)} ${String(pos.shares).padStart(4)} sh @ $${String(avgCost.toFixed(2)).padStart(8)} → $${String(price.toFixed(2)).padStart(8)} | MV: $${String(marketValue.toFixed(2)).padStart(10)} | P&L: ${pl >= 0 ? '+' : ''}${pl.toFixed(2)} (${plPct >= 0 ? '+' : ''}${plPct}%)${warn}`); + } + + if (positionLines.length === 0) { + console.log('No open positions.'); + } else { + positionLines.forEach(l => console.log(l)); + } + + const totalValue = totalMV + acc.balance; + const totalPLPct = acc.initialCapital > 0 ? +((totalValue / acc.initialCapital - 1) * 100).toFixed(2) : 0; + + console.log(''); + console.log('=== SUMMARY ==='); + console.log('Cash Balance: $' + acc.balance.toLocaleString(undefined, { minimumFractionDigits: 2 })); + console.log('Market Value: $' + totalMV.toLocaleString(undefined, { minimumFractionDigits: 2 })); + console.log('Total Value: $' + totalValue.toLocaleString(undefined, { minimumFractionDigits: 2 })); + console.log('Total P&L: ' + (totalPL >= 0 ? '+' : '') + '$' + totalPL.toLocaleString(undefined, { minimumFractionDigits: 2 }) + ' (' + (totalPLPct >= 0 ? '+' : '') + totalPLPct + '%)'); + console.log(''); + console.log('GRADE TARGET:'); + console.log(' S ($200k+): ' + (totalValue >= 200000 ? '✅' : '$' + (200000 - totalValue).toLocaleString() + ' away')); + console.log(' A ($120k+): ' + (totalValue >= 120000 ? '✅' : '$' + (120000 - totalValue).toLocaleString() + ' away')); + console.log(' B ($50k+): ' + (totalValue >= 50000 ? '✅' : '$' + (50000 - totalValue).toLocaleString() + ' away')); + console.log(' C (<$50k): ' + (totalValue < 50000 ? '⚠️ DANGER' : 'Safe')); + + await client.close(); +} + +main().catch((e) => { + console.error('FATAL:', e.message); + process.exit(1); +}); diff --git a/_scripts/execute_trades.js b/_scripts/execute_trades.js new file mode 100644 index 00000000..9772fd05 --- /dev/null +++ b/_scripts/execute_trades.js @@ -0,0 +1,112 @@ +const { MongoClient } = require('mongodb'); +const undici = require('undici'); +const fs = require('fs'); +const path = require('path'); + +const USER_ID = '6a17b7387348b73526f6a170'; +const PROXY = 'http://127.0.0.1:7890'; +const dispatcher = new undici.ProxyAgent(PROXY); +const fetch = (url) => undici.fetch(url, { dispatcher }); +const token = fs.readFileSync('.env', 'utf8').match(/NEXT_PUBLIC_FINNHUB_API_KEY=(\S+)/)?.[1] || ''; + +const stockData = { + 'META': { shares: 50, company: 'Meta Platforms Inc', price: 0 }, + 'NVDA': { shares: 100, company: 'NVIDIA Corp', price: 0 }, + 'AMZN': { shares: 50, company: 'Amazon.com Inc', price: 0 }, + 'SMCI': { shares: 200, company: 'Super Micro Computer Inc', price: 0 }, +}; + +async function main() { + // Get prices + for (const sym of Object.keys(stockData)) { + const r = await fetch('https://finnhub.io/api/v1/quote?symbol=' + sym + '&token=' + token); + const d = await r.json(); + stockData[sym].price = d.c || 0; + if (stockData[sym].price <= 0) { + console.log('ERROR: Invalid price for', sym, JSON.stringify(d)); + process.exit(1); + } + console.log(sym + ': $' + stockData[sym].price.toFixed(2)); + } + + // Connect to MongoDB + const client = new MongoClient('mongodb://localhost:27017'); + await client.connect(); + const db = client.db('openstock'); + const accounts = db.collection('paperaccounts'); + const trades = db.collection('papertrades'); + + // Get account + let acc = await accounts.findOne({ userId: USER_ID }); + if (!acc) { + console.log('Creating new account...'); + const r = await accounts.insertOne({ + userId: USER_ID, + balance: 100000, + initialCapital: 100000, + createdAt: new Date(), + updatedAt: new Date(), + }); + acc = await accounts.findOne({ userId: USER_ID }); + } + + console.log('Current balance: $' + acc.balance.toFixed(2)); + + // Calculate total cost + let totalCost = 0; + const orders = []; + for (const [sym, data] of Object.entries(stockData)) { + const cost = +(data.price * data.shares).toFixed(2); + totalCost += cost; + orders.push({ sym, shares: data.shares, price: data.price, cost, company: data.company }); + } + + if (totalCost > acc.balance) { + console.log('ERROR: Insufficient funds. Need $' + totalCost.toFixed(2) + ', have $' + acc.balance.toFixed(2)); + process.exit(1); + } + + // Execute trades + for (const o of orders) { + console.log('BUY ' + o.shares + ' x ' + o.sym + ' @ $' + o.price.toFixed(2) + ' = $' + o.cost.toFixed(2)); + await trades.insertOne({ + userId: USER_ID, + symbol: o.sym, + company: o.company, + type: 'BUY', + shares: o.shares, + price: o.price, + total: o.cost, + timestamp: new Date(), + }); + } + + // Update balance + const newBalance = +(acc.balance - totalCost).toFixed(2); + await accounts.updateOne( + { _id: acc._id }, + { $set: { balance: newBalance, updatedAt: new Date() } } + ); + + console.log('\n=== EXECUTION SUMMARY ==='); + console.log('Total invested: $' + totalCost.toFixed(2)); + console.log('Cash remaining: $' + newBalance.toFixed(2)); + console.log('Portfolio value: $' + (totalCost + newBalance).toFixed(2)); + + // Verify + const vAcc = await accounts.findOne({ userId: USER_ID }); + const vTrades = await trades.find({ userId: USER_ID }).sort({ timestamp: -1 }).toArray(); + console.log('\n=== VERIFICATION ==='); + console.log('Balance:', vAcc.balance); + console.log('Trades:', vTrades.length); + vTrades.forEach((t) => { + console.log(' ' + t.type + ' ' + t.shares + ' x ' + t.symbol + ' @ $' + t.price + ' = $' + t.total); + }); + + await client.close(); +} + +main().catch((e) => { + console.error('FATAL:', e); + process.exit(1); +}); diff --git a/_scripts/research_candidates.js b/_scripts/research_candidates.js new file mode 100644 index 00000000..1cc1ddda --- /dev/null +++ b/_scripts/research_candidates.js @@ -0,0 +1,80 @@ +const undici = require('undici'); +const fs = require('fs'); +const path = require('path'); + +const dispatcher = new undici.ProxyAgent('http://127.0.0.1:7890'); +const fetch = (url) => undici.fetch(url, { dispatcher }); +const token = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8').match(/NEXT_PUBLIC_FINNHUB_API_KEY=(\S+)/)?.[1] || ''; + +async function getProfile(sym) { + try { const r = await fetch('https://finnhub.io/api/v1/stock/profile2?symbol=' + sym + '&token=' + token); return await r.json(); } catch { return null; } +} +async function getQuote(sym) { + try { const r = await fetch('https://finnhub.io/api/v1/quote?symbol=' + sym + '&token=' + token); return await r.json(); } catch { return null; } +} +async function getPeers(sym) { + try { const r = await fetch('https://finnhub.io/api/v1/stock/peers?symbol=' + sym + '&token=' + token); return await r.json(); } catch { return []; } +} +async function getNews(sym) { + try { + const to = new Date(); + const from = new Date(Date.now() - 30 * 86400000); + const fStr = from.toISOString().slice(0,10); + const tStr = to.toISOString().slice(0,10); + const r = await fetch('https://finnhub.io/api/v1/company-news?symbol=' + sym + '&from=' + fStr + '&to=' + tStr + '&token=' + token); + return await r.json(); + } catch { return []; } +} + +async function main() { + // Focus on top 3 overlooked candidates + const candidates = [ + { sym: 'CEG', thesis: 'Nuclear power for AI data centers' }, + { sym: 'VICR', thesis: 'High-efficiency power modules for liquid cooling' }, + { sym: 'COHU', thesis: 'Semiconductor test equipment for advanced packaging' }, + { sym: 'AAOI', thesis: 'Optical interconnects for AI clusters' }, + ]; + + for (const s of candidates) { + console.log('\n========== ' + s.sym + ' =========='); + console.log('THESIS: ' + s.thesis); + + const p2 = await getProfile(s.sym); + if (p2 && p2.name) { + console.log('Name: ' + p2.name); + console.log('Industry: ' + (p2.finnhubIndustry || 'N/A')); + console.log('Exchange: ' + (p2.exchange || 'N/A')); + if (p2.marketCapitalization) console.log('MktCap: $' + (p2.marketCapitalization / 1e9).toFixed(1) + 'B'); + if (p2.shareOutstanding) console.log('Shares: ' + (p2.shareOutstanding / 1e9).toFixed(2) + 'B'); + if (p2.ipo) console.log('IPO: ' + p2.ipo); + } + + const q = await getQuote(s.sym); + if (q && q.c) { + const chg = q.dp >= 0 ? '+' : ''; + console.log('Price: $' + q.c + ' Prev: $' + q.pc + ' Chg: ' + chg + q.dp?.toFixed(2) + '%'); + console.log('Today Range: $' + (q.l || '?') + ' ~ $' + (q.h || '?')); + } + + const peers = await getPeers(s.sym); + if (peers && peers.length > 0) { + console.log('Peers: ' + peers.slice(0, 5).join(', ')); + } + + // Get recent news headline + const news = await getNews(s.sym); + if (Array.isArray(news) && news.length > 0) { + const headlines = news.slice(0, 3).map(n => ' - ' + (n.headline || '').slice(0, 100)); + console.log('Recent News:'); + headlines.forEach(h => console.log(h)); + } + } + + // Portfolio check + console.log('\n\n========== PORTFOLIO IMPACT =========='); + console.log('Current cash: $7,328.65'); + console.log('Max for new position at 70% cash = ~$5,100'); + console.log('Or sell existing to raise capital'); +} + +main().catch(console.error); diff --git a/_scripts/round2_trades.js b/_scripts/round2_trades.js new file mode 100644 index 00000000..cfb3aab4 --- /dev/null +++ b/_scripts/round2_trades.js @@ -0,0 +1,104 @@ +const { MongoClient } = require('mongodb'); +const undici = require('undici'); +const fs = require('fs'); +const path = require('path'); + +const USER_ID = '6a17b7387348b73526f6a170'; +const PROXY = 'http://127.0.0.1:7890'; +const dispatcher = new undici.ProxyAgent(PROXY); +const fetch = (url) => undici.fetch(url, { dispatcher }); +const token = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8').match(/NEXT_PUBLIC_FINNHUB_API_KEY=(\S+)/)?.[1] || ''; + +// New orders: MU (storage), add more NVDA (AI), add more SMCI (AI infra) +const orders = [ + { sym: 'MU', shares: 10, company: 'Micron Technology Inc' }, + { sym: 'NVDA', shares: 25, company: 'NVIDIA Corp' }, + { sym: 'SMCI', shares: 100, company: 'Super Micro Computer Inc' }, +]; + +async function main() { + // Get current prices + const prices = {}; + for (const o of orders) { + const r = await fetch('https://finnhub.io/api/v1/quote?symbol=' + o.sym + '&token=' + token); + const d = await r.json(); + prices[o.sym] = d.c || 0; + console.log(o.sym + ': $' + prices[o.sym].toFixed(2)); + } + + // Connect to MongoDB + const client = new MongoClient('mongodb://localhost:27017'); + await client.connect(); + const db = client.db('openstock'); + const accounts = db.collection('paperaccounts'); + const trades = db.collection('papertrades'); + + // Get account + const acc = await accounts.findOne({ userId: USER_ID }); + if (!acc) { console.log('No account found'); await client.close(); return; } + console.log('\nCurrent balance: $' + acc.balance.toFixed(2)); + + // Calculate costs + let totalCost = 0; + const executed = []; + for (const o of orders) { + const price = prices[o.sym]; + if (price <= 0) { console.log('SKIP ' + o.sym + ': invalid price'); continue; } + const cost = +(price * o.shares).toFixed(2); + totalCost += cost; + executed.push({ ...o, price, cost }); + } + + if (totalCost > acc.balance) { + console.log('Insufficient funds. Need $' + totalCost.toFixed(2) + ', have $' + acc.balance.toFixed(2)); + await client.close(); + return; + } + + // Execute + for (const e of executed) { + console.log('BUY ' + e.shares + ' x ' + e.sym + ' @ $' + e.price.toFixed(2) + ' = $' + e.cost.toFixed(2)); + await trades.insertOne({ + userId: USER_ID, + symbol: e.sym, + company: e.company, + type: 'BUY', + shares: e.shares, + price: e.price, + total: e.cost, + timestamp: new Date(), + }); + } + + // Update balance + const newBalance = +(acc.balance - totalCost).toFixed(2); + await accounts.updateOne( + { _id: acc._id }, + { $set: { balance: newBalance, updatedAt: new Date() } } + ); + + console.log('\n=== SECOND ROUND DEPLOYMENT ==='); + console.log('New investment: $' + totalCost.toFixed(2)); + console.log('Cash remaining: $' + newBalance.toFixed(2)); + + // Final verification + const vAcc = await accounts.findOne({ userId: USER_ID }); + const allTrades = await trades.find({ userId: USER_ID }).sort({ timestamp: -1 }).toArray(); + console.log('\n=== ALL POSITIONS ==='); + const posMap = {}; + for (const t of allTrades) { + if (!posMap[t.symbol]) posMap[t.symbol] = { shares: 0, cost: 0 }; + posMap[t.symbol].shares += t.type === 'BUY' ? t.shares : -t.shares; + posMap[t.symbol].cost += t.type === 'BUY' ? t.total : 0; + } + for (const [sym, pos] of Object.entries(posMap)) { + if (pos.shares > 0) { + console.log(sym.padEnd(6) + pos.shares + ' shares @ $' + (pos.cost / pos.shares).toFixed(2) + ' avg'); + } + } + console.log('\nCash: $' + vAcc.balance.toFixed(2)); + + await client.close(); +} + +main().catch((e) => { console.error('FATAL:', e); process.exit(1); }); diff --git a/_scripts/round3_trades.js b/_scripts/round3_trades.js new file mode 100644 index 00000000..491f6b6d --- /dev/null +++ b/_scripts/round3_trades.js @@ -0,0 +1,80 @@ +const { MongoClient } = require('mongodb'); +const undici = require('undici'); +const fs = require('fs'); +const path = require('path'); + +const USER_ID = '6a17b7387348b73526f6a170'; +const PROXY = 'http://127.0.0.1:7890'; +const dispatcher = new undici.ProxyAgent(PROXY); +const fetch = (url) => undici.fetch(url, { dispatcher }); +const token = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8').match(/NEXT_PUBLIC_FINNHUB_API_KEY=(\S+)/)?.[1] || ''; + +const orders = [ + { sym: 'COHU', shares: 50, company: 'Cohu Inc' }, + { sym: 'VICR', shares: 10, company: 'Vicor Corp' }, +]; + +async function main() { + const prices = {}; + for (const o of orders) { + const r = await fetch('https://finnhub.io/api/v1/quote?symbol=' + o.sym + '&token=' + token); + const d = await r.json(); + const roundedPrice = Math.round((d.c || 0) * 100) / 100; + prices[o.sym] = roundedPrice; + console.log(o.sym + ': $' + roundedPrice); + } + + const client = new MongoClient('mongodb://localhost:27017'); + await client.connect(); + const db = client.db('openstock'); + const accounts = db.collection('paperaccounts'); + const trades = db.collection('papertrades'); + + const acc = await accounts.findOne({ userId: USER_ID }); + if (!acc) { console.log('No account'); await client.close(); return; } + console.log('\nBalance: $' + acc.balance.toFixed(2)); + + let totalCost = 0; + for (const o of orders) { + const price = prices[o.sym]; + const cost = +(price * o.shares).toFixed(2); + totalCost += cost; + console.log('BUY ' + o.shares + ' x ' + o.sym + ' @ $' + price + ' = $' + cost.toFixed(2)); + await trades.insertOne({ + userId: USER_ID, symbol: o.sym, company: o.company, type: 'BUY', + shares: o.shares, price: price, total: cost, timestamp: new Date(), + }); + } + + const newBalance = +(acc.balance - totalCost).toFixed(2); + await accounts.updateOne({ _id: acc._id }, { $set: { balance: newBalance, updatedAt: new Date() } }); + + console.log('\n=== ROUND 3 DEPLOYMENT ==='); + console.log('Invested: $' + totalCost.toFixed(2)); + console.log('Cash left: $' + newBalance.toFixed(2)); + + // Final portfolio + const allTrades = await trades.find({ userId: USER_ID }).sort({ timestamp: -1 }).toArray(); + const posMap = {}; + for (const t of allTrades) { + if (!posMap[t.symbol]) posMap[t.symbol] = { shares: 0, cost: 0 }; + posMap[t.symbol].shares += t.type === 'BUY' ? t.shares : -t.shares; + posMap[t.symbol].cost += t.type === 'BUY' ? t.total : 0; + } + console.log('\n=== FINAL PORTFOLIO ==='); + let totalInvested = 0; + for (const [sym, pos] of Object.entries(posMap)) { + if (pos.shares > 0) { + const avg = pos.cost / pos.shares; + totalInvested += pos.cost; + const pct = (pos.cost / (totalInvested + newBalance - pos.cost + pos.cost) * 100).toFixed(1); + console.log(sym.padEnd(6) + pos.shares.toString().padStart(4) + ' sh @ $' + avg.toFixed(2).padStart(8) + ' = $' + pos.cost.toFixed(2).padStart(10)); + } + } + console.log('Cash'.padEnd(6) + ' $' + newBalance.toFixed(2).padStart(10)); + console.log('Total'.padEnd(6) + ' $' + (totalInvested + newBalance).toFixed(2).padStart(10)); + + await client.close(); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/_scripts/sector_analysis.js b/_scripts/sector_analysis.js new file mode 100644 index 00000000..0e973ea6 --- /dev/null +++ b/_scripts/sector_analysis.js @@ -0,0 +1,67 @@ +const undici = require('undici'); +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); + +const dispatcher = new undici.ProxyAgent('http://127.0.0.1:7890'); +const fetch = (url) => undici.fetch(url, { dispatcher }); +const token = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8').match(/NEXT_PUBLIC_FINNHUB_API_KEY=(\S+)/)?.[1] || ''; +const cacheDir = path.join(process.env.HOME, '.hermes', 'cache', 'finnhub'); +fs.mkdirSync(cacheDir, { recursive: true }); + +async function cachedFetch(url, ttl) { + const hash = crypto.createHash('md5').update(url).digest('hex'); + const cf = path.join(cacheDir, hash + '.json'); + try { + const c = JSON.parse(fs.readFileSync(cf, 'utf8')); + if (Date.now() - c.ts < ttl * 1000) return c.data; + } catch {} + const r = await fetch(url); + const d = await r.json(); + fs.writeFileSync(cf, JSON.stringify({ ts: Date.now(), data: d })); + return d; +} + +async function main() { + const stocks = [ + // Storage specific + { sym: 'WDC', name: 'Western Digital' }, + { sym: 'STX', name: 'Seagate Technology' }, + { sym: 'MU', name: 'Micron Technology' }, + // Data center / AI infra + { sym: 'NVDA', name: 'NVIDIA' }, + { sym: 'AMD', name: 'AMD' }, + { sym: 'SMCI', name: 'Super Micro' }, + { sym: 'AVGO', name: 'Broadcom' }, + { sym: 'MRVL', name: 'Marvell Tech' }, + // Semi equipment + { sym: 'AMAT', name: 'Applied Materials' }, + { sym: 'LRCX', name: 'Lam Research' }, + { sym: 'KLAC', name: 'KLA Corp' }, + // Legacy + { sym: 'INTC', name: 'Intel' }, + ]; + + console.log('=== STORAGE & SEMICONDUCTOR SECTOR ANALYSIS ==='); + console.log('Time:', new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })); + console.log('(Prices may be 15-min delayed from last close)\n'); + + for (const s of stocks) { + const q = await cachedFetch('https://finnhub.io/api/v1/quote?symbol=' + s.sym + '&token=' + token, 60); + const pp = await cachedFetch('https://finnhub.io/api/v1/stock/profile2?symbol=' + s.sym + '&token=' + token, 86400); + if (q && q.c) { + const chg = q.dp >= 0 ? '+' : ''; + const emoji = q.dp > 2 ? '🔥' : q.dp > 0 ? '📈' : q.dp > -2 ? '📉' : '💀'; + const mv = pp?.marketCapitalization ? '$' + (pp.marketCapitalization / 1e9).toFixed(1) + 'B' : ''; + console.log(emoji + ' ' + s.sym.padEnd(6) + s.name.padEnd(22) + '$' + String(q.c).padStart(8) + ' ' + chg + q.dp.toFixed(2) + '% ' + mv); + } + } + + console.log('\n=== OUR CURRENT HOLDINGS ==='); + console.log('NVDA: 100 shares @ $212.60'); + console.log('SMCI: 200 shares @ $38.19'); + console.log('Cash: $25,746.75'); + console.log('\nFor additional buys: remaining cash = $25,746.75 (25.7% of portfolio)'); +} + +main().catch(console.error); diff --git a/app/(root)/paper-trading/page.tsx b/app/(root)/paper-trading/page.tsx new file mode 100644 index 00000000..2f98afd1 --- /dev/null +++ b/app/(root)/paper-trading/page.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { auth } from '@/lib/better-auth/auth'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { getOrCreateAccount } from '@/lib/actions/paper-trading.actions'; +import PaperTradingDashboard from '@/components/paper-trading/PaperTradingDashboard'; + +export default async function PaperTradingPage() { + const session = await auth.api.getSession({ + headers: await headers() + }); + + if (!session) { + redirect('/sign-in'); + } + + const userId = session.user.id; + const accountId = await getOrCreateAccount(userId); + + return ( +
+ {/* Header */} +
+
+

Paper Trading

+

Multi-account simulation · Custom capital & period · AI-assisted trading

+
+
+ + Live prices from Finnhub +
+
+ + {/* Dashboard */} + +
+ ); +} diff --git a/components/paper-trading/AITradingPanel.tsx b/components/paper-trading/AITradingPanel.tsx new file mode 100644 index 00000000..a9da8ccb --- /dev/null +++ b/components/paper-trading/AITradingPanel.tsx @@ -0,0 +1,431 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { Bot, Play, Save, Settings, Shield, TrendingUp, Zap, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; + +interface AIConfig { + enabled: boolean; + apiEndpoint: string; + apiKey: string; + model: string; + systemPrompt: string; + strategy: string; + maxPositionPct: number; + stopLossPct: number; + tradingIntervalMin: number; + lastTradeAt: string | null; +} + +interface AITradeResult { + decisions: { action: string; symbol: string; shares?: number; reason: string }[]; + executed: { symbol: string; action: string; shares: number; price: number; total: number }[]; + errors: string[]; + summary: string; +} + +const STRATEGY_INFO: Record = { + aggressive: { icon: TrendingUp, label: 'Aggressive', desc: 'Maximize short-term gains, high risk tolerance', color: 'text-red-400' }, + moderate: { icon: Shield, label: 'Moderate', desc: 'Balanced risk & reward', color: 'text-yellow-400' }, + conservative: { icon: Shield, label: 'Conservative', desc: 'Capital preservation first', color: 'text-green-400' }, + custom: { icon: Settings, label: 'Custom', desc: 'Fully customized trading strategy', color: 'text-purple-400' }, +}; + +const STRATEGY_PRESET_PROMPTS: Record = { + aggressive: `You are an aggressive stock trading AI. Your goal is to maximize short-term returns and you are willing to accept higher risk. +- Prefer high-volatility, high-growth-potential stocks (e.g. AI, semiconductors, clean energy) +- Single position can reach up to 25% of account +- Stop-loss set at -10% +- Actively seek breakout and momentum trading opportunities +- Quick entries and exits; do not hold losing positions long-term +- Pay attention to market sentiment and news-driven events`, + + moderate: `You are a moderate stock trading AI. You seek a balance between risk and reward. +- Prefer mid-to-large cap tech stocks with solid fundamentals +- Single position limit of 20% of account +- Stop-loss set at -8% +- Combine technical and fundamental analysis +- Holding period of 1-4 weeks +- Diversify across sectors; avoid over-concentration`, + + conservative: `You are a conservative stock trading AI. Your primary goal is capital preservation, with growth as a secondary priority. +- Prefer blue-chip stocks and index ETFs +- Single position limit of 10% of account +- Stop-loss set at -5% +- Only act on high-conviction opportunities +- Maintain at least 20% cash as a safety cushion +- Strictly avoid chasing highs or catching falling knives`, + custom: '', +}; +export default function AITradingPanel({ accountId, userId }: { accountId: string; userId: string }) { + const [config, setConfig] = useState(null); + const [expanded, setExpanded] = useState(false); + const [saving, setSaving] = useState(false); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null); + + // Form state + const [apiEndpoint, setApiEndpoint] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(''); + const [systemPrompt, setSystemPrompt] = useState(''); + const [strategy, setStrategy] = useState('moderate'); + const [maxPositionPct, setMaxPositionPct] = useState(25); + const [stopLossPct, setStopLossPct] = useState(-10); + const [tradingIntervalMin, setTradingIntervalMin] = useState(60); + + const loadConfig = useCallback(async () => { + const { getAIConfig } = await import('@/lib/actions/ai-trading.actions'); + const cfg = await getAIConfig(accountId); + if (cfg) { + setConfig(cfg); + setApiEndpoint(cfg.apiEndpoint); + setApiKey(cfg.apiKey); + setModel(cfg.model); + setSystemPrompt(cfg.systemPrompt); + setStrategy(cfg.strategy); + setMaxPositionPct(cfg.maxPositionPct); + setStopLossPct(cfg.stopLossPct); + setTradingIntervalMin(cfg.tradingIntervalMin); + } + }, [accountId]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const handleSave = async () => { + setSaving(true); + setMessage(null); + const { saveAIConfig } = await import('@/lib/actions/ai-trading.actions'); + const res = await saveAIConfig(accountId, userId, { + apiEndpoint, + apiKey, + model, + systemPrompt, + strategy, + maxPositionPct, + stopLossPct, + tradingIntervalMin, + }); + setSaving(false); + if (res.success) { + setMessage({ text: 'AI config saved', type: 'success' }); + setConfig(res.config); + } else { + setMessage({ text: res.error || 'Save failed', type: 'error' }); + } + }; + + const handleToggle = async () => { + const newEnabled = !config?.enabled; + const { toggleAITrading } = await import('@/lib/actions/ai-trading.actions'); + const res = await toggleAITrading(userId, newEnabled); + if (res.success) { + setConfig(prev => prev ? { ...prev, enabled: newEnabled } : null); + setMessage({ text: newEnabled ? 'AI auto-trading enabled' : 'AI auto-trading stopped', type: 'success' }); + } + }; + + const handleRun = async () => { + setRunning(true); + setResult(null); + setMessage(null); + const { runAITradeCycle } = await import('@/lib/actions/ai-trading.actions'); + const res = await runAITradeCycle(userId); + setResult(res); + setRunning(false); + if (res.errors.length > 0) { + setMessage({ text: res.errors[0], type: 'error' }); + } else if (res.executed.length > 0) { + setMessage({ text: `${res.executed.length} trade(s) executed`, type: 'success' }); + } else { + setMessage({ text: 'AI analysis complete — no action needed', type: 'success' }); + } + }; + + const handleStrategyPreset = (s: string) => { + setStrategy(s); + if (s === 'custom') return; // Don't override anything for custom + setSystemPrompt(STRATEGY_PRESET_PROMPTS[s] || ''); + if (s === 'aggressive') { setMaxPositionPct(25); setStopLossPct(-10); } + if (s === 'moderate') { setMaxPositionPct(20); setStopLossPct(-8); } + if (s === 'conservative') { setMaxPositionPct(10); setStopLossPct(-5); } + }; + + const si = STRATEGY_INFO[strategy] || STRATEGY_INFO.moderate; + const StrategyIcon = si.icon; + + return ( +
+ {/* Header */} +
setExpanded(!expanded)} + > +
+
+ +
+
+

+ AI Auto Trading + {config?.enabled && ( + + Running + + )} +

+

+ {config?.enabled + ? `Strategy: ${si.label} · Check every ${config.tradingIntervalMin} min` + : 'Configure LLM API for AI auto-trading'} +

+
+
+
+ {config?.enabled && ( + + )} + {expanded ? : } +
+
+ + {/* Expanded Content */} + {expanded && ( +
+ {/* Message */} + {message && ( +
+ {message.text} +
+ )} + + {/* Enable Toggle */} +
+
+

Enable AI Auto Trading

+

+ AI will analyze markets and execute trades on your configured schedule +

+
+ +
+ + {/* Strategy Presets */} +
+ +
+ {Object.entries(STRATEGY_INFO).map(([key, info]) => { + const Icon = info.icon; + return ( + + ); + })} +
+
+ + {strategy === 'custom' && ( +
+ 💡 Custom mode: Write your full trading strategy in the System Prompt below. + Advanced parameters (position sizing, stop-loss, etc.) are fully under your control. +
+ )} + + {/* API Config */} +
+
+ + setApiEndpoint(e.target.value)} + placeholder="https://api.openai.com/v1/chat/completions" + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-teal-500" + /> +
+
+ + setModel(e.target.value)} + placeholder="gpt-4o / claude-3-opus / deepseek-v3" + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-teal-500" + /> +
+
+ + setApiKey(e.target.value)} + placeholder="sk-..." + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-teal-500" + /> +
+
+ + {/* Advanced Settings */} +
+
+ + setMaxPositionPct(Number(e.target.value))} + min={5} max={100} + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-teal-500" + /> +
+
+ + setStopLossPct(Number(e.target.value))} + max={0} min={-50} + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-teal-500" + /> +
+
+ + setTradingIntervalMin(Number(e.target.value))} + min={5} max={1440} + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-teal-500" + /> +
+
+ + {/* System Prompt */} +
+ +