feat: complete trading system with FastAPI backend, web frontend, and auto-analysis
This commit is contained in:
466
frontend/app.js
Normal file
466
frontend/app.js
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Stock Buddy 前端逻辑
|
||||
*/
|
||||
|
||||
const API_BASE = 'http://localhost:8000/api';
|
||||
|
||||
// 页面状态
|
||||
let currentPage = 'dashboard';
|
||||
let positions = [];
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// 初始化
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initNavigation();
|
||||
initEventListeners();
|
||||
loadDashboard();
|
||||
});
|
||||
|
||||
function initNavigation() {
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const page = item.dataset.page;
|
||||
switchPage(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initEventListeners() {
|
||||
// 刷新按钮
|
||||
document.getElementById('refresh-btn').addEventListener('click', () => {
|
||||
loadCurrentPage();
|
||||
});
|
||||
|
||||
// 运行分析
|
||||
document.getElementById('run-analysis-btn').addEventListener('click', async () => {
|
||||
await runDailyAnalysis();
|
||||
});
|
||||
|
||||
// 添加持仓
|
||||
document.getElementById('add-position-btn').addEventListener('click', () => {
|
||||
showModal('add-position-modal');
|
||||
});
|
||||
|
||||
document.getElementById('cancel-add').addEventListener('click', () => {
|
||||
hideModal('add-position-modal');
|
||||
});
|
||||
|
||||
document.querySelector('.modal-close').addEventListener('click', () => {
|
||||
hideModal('add-position-modal');
|
||||
});
|
||||
|
||||
document.getElementById('add-position-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await addPosition();
|
||||
});
|
||||
|
||||
// 股票分析
|
||||
document.getElementById('analyze-btn').addEventListener('click', async () => {
|
||||
const input = document.getElementById('stock-search').value.trim();
|
||||
if (input) {
|
||||
await analyzeStock(input);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('stock-search').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('analyze-btn').click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// 页面切换
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
function switchPage(page) {
|
||||
currentPage = page;
|
||||
|
||||
// 更新导航状态
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
if (item.dataset.page === page) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 切换页面内容
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById(`page-${page}`).classList.add('active');
|
||||
|
||||
// 更新标题
|
||||
const titles = {
|
||||
dashboard: '总览',
|
||||
positions: '持仓管理',
|
||||
analysis: '股票分析',
|
||||
sentiment: '舆情监控',
|
||||
settings: '设置'
|
||||
};
|
||||
document.getElementById('page-title').textContent = titles[page];
|
||||
|
||||
// 加载页面数据
|
||||
loadCurrentPage();
|
||||
}
|
||||
|
||||
function loadCurrentPage() {
|
||||
switch (currentPage) {
|
||||
case 'dashboard':
|
||||
loadDashboard();
|
||||
break;
|
||||
case 'positions':
|
||||
loadPositions();
|
||||
break;
|
||||
case 'sentiment':
|
||||
loadSentiment();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// 数据加载
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/positions`);
|
||||
positions = await response.json();
|
||||
|
||||
updateDashboardStats();
|
||||
renderPositionsTable();
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error);
|
||||
showError('数据加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboardStats() {
|
||||
const totalValue = positions.reduce((sum, p) => sum + (p.market_value || 0), 0);
|
||||
const totalCost = positions.reduce((sum, p) => sum + (p.shares * p.cost_price), 0);
|
||||
const totalPnl = totalValue - totalCost;
|
||||
const totalPnlPercent = totalCost > 0 ? (totalPnl / totalCost) * 100 : 0;
|
||||
|
||||
document.getElementById('total-value').textContent = formatMoney(totalValue);
|
||||
document.getElementById('total-pnl').textContent = `${totalPnl >= 0 ? '+' : ''}${formatMoney(totalPnl)} (${totalPnlPercent.toFixed(2)}%)`;
|
||||
document.getElementById('total-pnl').className = `stat-change ${totalPnl >= 0 ? 'positive' : 'negative'}`;
|
||||
|
||||
document.getElementById('position-count').textContent = positions.length;
|
||||
|
||||
// 统计买入信号
|
||||
const buySignals = positions.filter(p => p.pnl_percent < -5).length; // 简化逻辑
|
||||
document.getElementById('buy-signals').textContent = buySignals;
|
||||
}
|
||||
|
||||
function renderPositionsTable() {
|
||||
const tbody = document.getElementById('positions-tbody');
|
||||
|
||||
if (positions.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="empty-state">暂无持仓</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = positions.map(p => {
|
||||
const pnlClass = p.pnl >= 0 ? 'positive' : 'negative';
|
||||
const signal = p.pnl_percent < -10 ? 'BUY' : p.pnl_percent > 20 ? 'SELL' : 'HOLD';
|
||||
const signalClass = signal.toLowerCase();
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${p.stock_name}</strong></td>
|
||||
<td>${p.ticker}</td>
|
||||
<td>${p.shares}</td>
|
||||
<td>${p.cost_price.toFixed(3)}</td>
|
||||
<td>${(p.current_price || 0).toFixed(3)}</td>
|
||||
<td>${formatMoney(p.market_value || 0)}</td>
|
||||
<td class="${pnlClass}">${p.pnl >= 0 ? '+' : ''}${formatMoney(p.pnl)} (${p.pnl_percent.toFixed(2)}%)</td>
|
||||
<td><span class="signal-tag ${signalClass}">${signal}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm" onclick="viewPosition(${p.id})">详情</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadPositions() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/positions`);
|
||||
positions = await response.json();
|
||||
|
||||
const tbody = document.getElementById('manage-positions-tbody');
|
||||
|
||||
if (positions.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">暂无持仓</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = positions.map(p => {
|
||||
const pnlClass = p.pnl >= 0 ? 'positive' : 'negative';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${p.stock_name}</td>
|
||||
<td>${p.ticker}</td>
|
||||
<td>${p.shares}</td>
|
||||
<td>${p.cost_price.toFixed(3)}</td>
|
||||
<td>${(p.current_price || 0).toFixed(3)}</td>
|
||||
<td class="${pnlClass}">${p.pnl >= 0 ? '+' : ''}${formatMoney(p.pnl)}</td>
|
||||
<td>${p.strategy}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deletePosition(${p.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSentiment() {
|
||||
const list = document.getElementById('sentiment-list');
|
||||
|
||||
if (positions.length === 0) {
|
||||
list.innerHTML = '<p class="empty-state">请先添加持仓股票</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = '<p class="empty-state">舆情分析功能开发中...</p>';
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// 持仓操作
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function addPosition() {
|
||||
const data = {
|
||||
stock_name: document.getElementById('pos-name').value,
|
||||
ticker: document.getElementById('pos-ticker').value,
|
||||
shares: parseInt(document.getElementById('pos-shares').value),
|
||||
cost_price: parseFloat(document.getElementById('pos-cost').value),
|
||||
strategy: document.getElementById('pos-strategy').value,
|
||||
notes: document.getElementById('pos-notes').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/positions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
hideModal('add-position-modal');
|
||||
document.getElementById('add-position-form').reset();
|
||||
loadCurrentPage();
|
||||
showSuccess('持仓添加成功');
|
||||
} else {
|
||||
throw new Error('添加失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('添加失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePosition(id) {
|
||||
if (!confirm('确定删除此持仓吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/positions/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadPositions();
|
||||
showSuccess('删除成功');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// 股票分析
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function analyzeStock(input) {
|
||||
const resultDiv = document.getElementById('analysis-result');
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.innerHTML = '<div class="result-loading">分析中,请稍候...</div>';
|
||||
|
||||
try {
|
||||
// 判断是名称还是代码
|
||||
const isTicker = input.endsWith('.HK') || /^\d{4,5}$/.test(input);
|
||||
|
||||
const requestData = isTicker
|
||||
? { stock_name: input, ticker: input }
|
||||
: { stock_name: input };
|
||||
|
||||
const response = await fetch(`${API_BASE}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
renderAnalysisResult(result);
|
||||
} else {
|
||||
throw new Error(result.detail || '分析失败');
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `<div class="result-card"><p class="negative">分析失败: ${error.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnalysisResult(result) {
|
||||
const resultDiv = document.getElementById('analysis-result');
|
||||
|
||||
const signalClass = result.signal.action.toLowerCase();
|
||||
const sentimentClass = result.sentiment.score >= 0 ? 'positive' : 'negative';
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<div class="result-card">
|
||||
<div class="result-header">
|
||||
<div>
|
||||
<div class="result-title">${result.stock_name} (${result.ticker})</div>
|
||||
<div style="color: var(--text-secondary); font-size: 13px; margin-top: 4px;">
|
||||
分析时间: ${new Date(result.timestamp).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span class="signal-tag ${signalClass}">${result.signal.action}</span>
|
||||
</div>
|
||||
|
||||
<div class="result-body">
|
||||
<div class="result-item">
|
||||
<div class="result-label">综合评分</div>
|
||||
<div class="result-value" style="color: ${result.signal.score >= 0 ? 'var(--success)' : 'var(--danger)'}">
|
||||
${result.signal.score >= 0 ? '+' : ''}${result.signal.score.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<div class="result-label">当前价格</div>
|
||||
<div class="result-value">${result.technical.current_price.toFixed(3)}</div>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<div class="result-label">建议仓位</div>
|
||||
<div class="result-value">${(result.signal.position_ratio * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-card">
|
||||
<h4 style="margin-bottom: 16px;">技术分析</h4>
|
||||
<div class="result-body">
|
||||
<div class="result-item">
|
||||
<div class="result-label">RSI</div>
|
||||
<div class="result-value">${result.technical.rsi.toFixed(1)}</div>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<div class="result-label">趋势</div>
|
||||
<div class="result-value" style="font-size: 16px;">${result.technical.trend === 'UP' ? '上涨📈' : result.technical.trend === 'DOWN' ? '下跌📉' : '震荡➡️'}</div>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<div class="result-label">止损位</div>
|
||||
<div class="result-value">${(result.signal.stop_loss * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-card">
|
||||
<h4 style="margin-bottom: 16px;">舆情分析</h4>
|
||||
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 12px;">
|
||||
<span style="font-size: 32px; font-weight: 700; color: ${sentimentClass};">
|
||||
${result.sentiment.score > 0 ? '+' : ''}${result.sentiment.score}
|
||||
</span>
|
||||
<span>${result.sentiment.label}</span>
|
||||
</div>
|
||||
<div style="color: var(--text-secondary); font-size: 14px;">
|
||||
<strong>影响因素:</strong> ${result.sentiment.factors.join('、')}
|
||||
</div>
|
||||
<div style="color: var(--text-secondary); font-size: 14px; margin-top: 8px;">
|
||||
<strong>展望:</strong> ${result.sentiment.outlook}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-card">
|
||||
<h4 style="margin-bottom: 16px;">分析理由</h4>
|
||||
<ul style="color: var(--text-secondary); font-size: 14px; padding-left: 20px;">
|
||||
${result.signal.reasons.map(r => `<li>${r}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// 任务操作
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function runDailyAnalysis() {
|
||||
const btn = document.getElementById('run-analysis-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span>⏳</span> 分析中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/tasks/daily-analysis`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showSuccess('每日分析任务已启动');
|
||||
} else {
|
||||
throw new Error('启动失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('启动失败: ' + error.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<span>▶️</span> 运行分析';
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
function formatMoney(amount) {
|
||||
if (amount === undefined || amount === null) return '--';
|
||||
if (amount >= 100000000) {
|
||||
return (amount / 100000000).toFixed(2) + '亿';
|
||||
} else if (amount >= 10000) {
|
||||
return (amount / 10000).toFixed(2) + '万';
|
||||
}
|
||||
return amount.toFixed(2);
|
||||
}
|
||||
|
||||
function showModal(id) {
|
||||
document.getElementById(id).classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideModal(id) {
|
||||
document.getElementById(id).classList.add('hidden');
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
// 简化实现,实际可用 toast
|
||||
console.log('✅', message);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
console.error('❌', message);
|
||||
alert(message);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
// 其他功能
|
||||
// ═════════════════════════════════════════════════════════════════════
|
||||
|
||||
function viewPosition(id) {
|
||||
const pos = positions.find(p => p.id === id);
|
||||
if (pos) {
|
||||
alert(`股票: ${pos.stock_name}\n代码: ${pos.ticker}\n持仓: ${pos.shares}股\n成本: ${pos.cost_price}\n策略: ${pos.strategy}\n\n${pos.notes || ''}`);
|
||||
}
|
||||
}
|
||||
263
frontend/index.html
Normal file
263
frontend/index.html
Normal file
@@ -0,0 +1,263 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stock Buddy - 港股AI交易系统</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">📈</span>
|
||||
<span class="logo-text">Stock Buddy</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<a href="#" class="nav-item active" data-page="dashboard">
|
||||
<span class="nav-icon">🏠</span>
|
||||
<span>总览</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="positions">
|
||||
<span class="nav-icon">💼</span>
|
||||
<span>持仓管理</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="analysis">
|
||||
<span class="nav-icon">🔍</span>
|
||||
<span>股票分析</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="sentiment">
|
||||
<span class="nav-icon">📰</span>
|
||||
<span>舆情监控</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="settings">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span>设置</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="market-status">
|
||||
<span class="status-dot green"></span>
|
||||
<span>市场状态: 交易中</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="header">
|
||||
<h1 id="page-title">总览</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" id="refresh-btn">
|
||||
<span>🔄</span> 刷新数据
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="run-analysis-btn">
|
||||
<span>▶️</span> 运行分析
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<div class="content" id="content-area">
|
||||
<!-- 总览页 -->
|
||||
<div id="page-dashboard" class="page active">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总持仓市值</div>
|
||||
<div class="stat-value" id="total-value">--</div>
|
||||
<div class="stat-change" id="total-pnl">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">今日盈亏</div>
|
||||
<div class="stat-value" id="daily-pnl">--</div>
|
||||
<div class="stat-change" id="daily-pnl-percent">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">持仓数量</div>
|
||||
<div class="stat-value" id="position-count">--</div>
|
||||
<div class="stat-label-small">只股票</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">买入信号</div>
|
||||
<div class="stat-value buy" id="buy-signals">--</div>
|
||||
<div class="stat-label-small">个机会</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 持仓列表 -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">持仓股票</h2>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="positions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>股票</th>
|
||||
<th>代码</th>
|
||||
<th>持仓</th>
|
||||
<th>成本价</th>
|
||||
<th>现价</th>
|
||||
<th>市值</th>
|
||||
<th>盈亏</th>
|
||||
<th>信号</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="positions-tbody">
|
||||
<tr>
|
||||
<td colspan="9" class="loading">加载中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 持仓管理页 -->
|
||||
<div id="page-positions" class="page">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">持仓列表</h2>
|
||||
<button class="btn btn-primary" id="add-position-btn">
|
||||
<span>+</span> 添加持仓
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="manage-positions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>股票名称</th>
|
||||
<th>股票代码</th>
|
||||
<th>持仓数量</th>
|
||||
<th>成本价</th>
|
||||
<th>当前价</th>
|
||||
<th>盈亏</th>
|
||||
<th>策略</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-positions-tbody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading">加载中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 股票分析页 -->
|
||||
<div id="page-analysis" class="page">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">股票分析</h2>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-box">
|
||||
<input type="text" id="stock-search" placeholder="输入股票名称或代码(如:中芯国际 或 0981.HK)">
|
||||
<button class="btn btn-primary" id="analyze-btn">分析</button>
|
||||
</div>
|
||||
|
||||
<!-- 分析结果 -->
|
||||
<div id="analysis-result" class="analysis-result hidden">
|
||||
<div class="result-loading">分析中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 舆情监控页 -->
|
||||
<div id="page-sentiment" class="page">
|
||||
<div class="section">
|
||||
<h2 class="section-title">舆情监控</h2>
|
||||
<div id="sentiment-list">
|
||||
<p class="empty-state">选择持仓股票查看舆情分析</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置页 -->
|
||||
<div id="page-settings" class="page">
|
||||
<div class="section">
|
||||
<h2 class="section-title">系统设置</h2>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
<label>默认策略</label>
|
||||
<select id="default-strategy">
|
||||
<option value="A">A - 固定止损12%</option>
|
||||
<option value="B">B - ATR动态止损</option>
|
||||
<option value="C" selected>C - 混合自适应</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>大盘过滤</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="market-filter" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-label">启用恒生指数MA20过滤</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>自动分析时间</label>
|
||||
<input type="time" id="auto-analysis-time" value="09:00">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="save-settings">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 添加持仓弹窗 -->
|
||||
<div class="modal hidden" id="add-position-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>添加持仓</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<form id="add-position-form">
|
||||
<div class="form-group">
|
||||
<label>股票名称</label>
|
||||
<input type="text" id="pos-name" required placeholder="如:中芯国际">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>股票代码</label>
|
||||
<input type="text" id="pos-ticker" required placeholder="如:0981.HK">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>持仓数量</label>
|
||||
<input type="number" id="pos-shares" required min="1" placeholder="1000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>成本价</label>
|
||||
<input type="number" id="pos-cost" required step="0.001" placeholder="50.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>策略</label>
|
||||
<select id="pos-strategy">
|
||||
<option value="C" selected>C - 混合自适应</option>
|
||||
<option value="A">A - 固定止损12%</option>
|
||||
<option value="B">B - ATR动态止损</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>备注</label>
|
||||
<textarea id="pos-notes" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancel-add">取消</button>
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
608
frontend/style.css
Normal file
608
frontend/style.css
Normal file
@@ -0,0 +1,608 @@
|
||||
/* Stock Buddy 样式 */
|
||||
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--secondary: #64748b;
|
||||
--success: #22c55e;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--border: #334155;
|
||||
--radius: 8px;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 24px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.market-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.green {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 64px;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 页面切换 */
|
||||
.page {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
padding: 24px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value.buy {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-value.sell {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.stat-label-small {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 20px 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 16px 24px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.data-table .positive {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.data-table .negative {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.data-table .loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* 信号标签 */
|
||||
.signal-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.signal-tag.buy {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.signal-tag.sell {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.signal-tag.hold {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* 分析结果 */
|
||||
.analysis-result {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.analysis-result.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 弹窗 */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
form {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 设置页 */
|
||||
.settings-form {
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider::after {
|
||||
left: 26px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.logo-text,
|
||||
.nav-item span:not(.nav-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user