| {% extends "layout.html" %}
|
|
|
| {% block title %}投资组合 - 智能分析系统{% endblock %}
|
|
|
| {% block content %}
|
| <div class="container-fluid py-4">
|
| <div id="alerts-container"></div>
|
|
|
| <div class="row mb-4">
|
| <div class="col-12">
|
| <div class="card">
|
| <div class="card-header d-flex justify-content-between">
|
| <h5 class="mb-0">我的投资组合</h5>
|
| <button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addStockModal">
|
| <i class="fas fa-plus"></i> 添加股票
|
| </button>
|
| </div>
|
| <div class="card-body">
|
| <div id="portfolio-empty" class="text-center py-4">
|
| <i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
|
| <p>您的投资组合还是空的,请添加股票</p>
|
| <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addStockModal">
|
| <i class="fas fa-plus"></i> 添加股票
|
| </button>
|
| </div>
|
|
|
| <div id="portfolio-content" style="display: none;">
|
| <div class="table-responsive">
|
| <table class="table table-hover">
|
| <thead>
|
| <tr>
|
| <th>代码</th>
|
| <th>名称</th>
|
| <th>行业</th>
|
| <th>持仓比例</th>
|
| <th>当前价格</th>
|
| <th>今日涨跌</th>
|
| <th>综合评分</th>
|
| <th>建议</th>
|
| <th>操作</th>
|
| </tr>
|
| </thead>
|
| <tbody id="portfolio-table">
|
|
|
| </tbody>
|
| </table>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div id="portfolio-analysis" class="row mb-4" style="display: none;">
|
| <div class="col-md-6">
|
| <div class="card h-100">
|
| <div class="card-header">
|
| <h5 class="mb-0">投资组合评分</h5>
|
| </div>
|
| <div class="card-body">
|
| <div class="row">
|
| <div class="col-md-4 text-center">
|
| <div id="portfolio-score-chart"></div>
|
| <h4 id="portfolio-score" class="mt-2">--</h4>
|
| <p class="text-muted">综合评分</p>
|
| </div>
|
| <div class="col-md-8">
|
| <h5 class="mb-3">维度评分</h5>
|
| <div class="mb-3">
|
| <div class="d-flex justify-content-between mb-1">
|
| <span>技术面</span>
|
| <span id="technical-score">--/40</span>
|
| </div>
|
| <div class="progress">
|
| <div id="technical-progress" class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
|
| </div>
|
| </div>
|
| <div class="mb-3">
|
| <div class="d-flex justify-content-between mb-1">
|
| <span>基本面</span>
|
| <span id="fundamental-score">--/40</span>
|
| </div>
|
| <div class="progress">
|
| <div id="fundamental-progress" class="progress-bar bg-success" role="progressbar" style="width: 0%"></div>
|
| </div>
|
| </div>
|
| <div class="mb-3">
|
| <div class="d-flex justify-content-between mb-1">
|
| <span>资金面</span>
|
| <span id="capital-flow-score">--/20</span>
|
| </div>
|
| <div class="progress">
|
| <div id="capital-flow-progress" class="progress-bar bg-warning" role="progressbar" style="width: 0%"></div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| <div class="col-md-6">
|
| <div class="card h-100">
|
| <div class="card-header">
|
| <h5 class="mb-0">行业分布</h5>
|
| </div>
|
| <div class="card-body">
|
| <div id="industry-chart"></div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div id="portfolio-recommendations" class="row mb-4" style="display: none;">
|
| <div class="col-12">
|
| <div class="card">
|
| <div class="card-header">
|
| <h5 class="mb-0">投资建议</h5>
|
| </div>
|
| <div class="card-body">
|
| <ul class="list-group" id="recommendations-list">
|
|
|
| </ul>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
|
|
|
|
| <div class="modal fade" id="addStockModal" tabindex="-1" aria-labelledby="addStockModalLabel" aria-hidden="true">
|
| <div class="modal-dialog">
|
| <div class="modal-content">
|
| <div class="modal-header">
|
| <h5 class="modal-title" id="addStockModalLabel">添加股票到投资组合</h5>
|
| <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| </div>
|
| <div class="modal-body">
|
| <form id="add-stock-form">
|
| <div class="mb-3">
|
| <label for="add-stock-code" class="form-label">股票代码</label>
|
| <input type="text" class="form-control" id="add-stock-code" required>
|
| </div>
|
| <div class="mb-3">
|
| <label for="add-stock-weight" class="form-label">持仓比例 (%)</label>
|
| <input type="number" class="form-control" id="add-stock-weight" min="1" max="100" value="10" required>
|
| </div>
|
| </form>
|
| </div>
|
| <div class="modal-footer">
|
| <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
| <button type="button" class="btn btn-primary" id="add-stock-btn">添加</button>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| {% endblock %}
|
|
|
| {% block scripts %}
|
| <script>
|
|
|
| let portfolio = [];
|
| let portfolioAnalysis = null;
|
|
|
| $(document).ready(function() {
|
|
|
| loadPortfolio();
|
|
|
|
|
| $('#add-stock-btn').click(function() {
|
| addStockToPortfolio();
|
| });
|
| });
|
|
|
|
|
| function loadPortfolio() {
|
| const savedPortfolio = localStorage.getItem('portfolio');
|
| if (savedPortfolio) {
|
| portfolio = JSON.parse(savedPortfolio);
|
| renderPortfolio();
|
| analyzePortfolio();
|
| }
|
| }
|
|
|
|
|
| function renderPortfolio() {
|
| if (portfolio.length === 0) {
|
| $('#portfolio-empty').show();
|
| $('#portfolio-content').hide();
|
| $('#portfolio-analysis').hide();
|
| $('#portfolio-recommendations').hide();
|
| return;
|
| }
|
|
|
| $('#portfolio-empty').hide();
|
| $('#portfolio-content').show();
|
| $('#portfolio-analysis').show();
|
| $('#portfolio-recommendations').show();
|
|
|
| let html = '';
|
| portfolio.forEach((stock, index) => {
|
| const scoreClass = getScoreColorClass(stock.score || 0);
|
| const priceChangeClass = (stock.price_change || 0) >= 0 ? 'trend-up' : 'trend-down';
|
| const priceChangeIcon = (stock.price_change || 0) >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
|
|
|
|
| const stockName = stock.loading ?
|
| '<span class="text-muted"><i class="fas fa-spinner fa-pulse"></i> 加载中...</span>' :
|
| (stock.stock_name || '未知');
|
|
|
| const industryDisplay = stock.industry || '-';
|
|
|
| html += `
|
| <tr>
|
| <td>${stock.stock_code}</td>
|
| <td>${stockName}</td>
|
| <td>${industryDisplay}</td>
|
| <td>${stock.weight}%</td>
|
| <td>${stock.price ? formatNumber(stock.price, 2) : '-'}</td>
|
| <td class="${priceChangeClass}">${stock.price_change ? (priceChangeIcon + ' ' + formatPercent(stock.price_change, 2)) : '-'}</td>
|
| <td><span class="badge ${scoreClass}">${stock.score || '-'}</span></td>
|
| <td>${stock.recommendation || '-'}</td>
|
| <td>
|
| <div class="btn-group btn-group-sm" role="group">
|
| <a href="/stock_detail/${stock.stock_code}" class="btn btn-outline-primary">
|
| <i class="fas fa-chart-line"></i>
|
| </a>
|
| <button type="button" class="btn btn-outline-danger" onclick="removeStock(${index})">
|
| <i class="fas fa-trash"></i>
|
| </button>
|
| </div>
|
| </td>
|
| </tr>
|
| `;
|
| });
|
|
|
| $('#portfolio-table').html(html);
|
| }
|
|
|
|
|
| function addStockToPortfolio() {
|
| const stockCode = $('#add-stock-code').val().trim();
|
| const weight = parseInt($('#add-stock-weight').val() || 10);
|
|
|
| if (!stockCode) {
|
| showError('请输入股票代码');
|
| return;
|
| }
|
|
|
|
|
| const existingIndex = portfolio.findIndex(s => s.stock_code === stockCode);
|
| if (existingIndex >= 0) {
|
| showError('此股票已在投资组合中');
|
| return;
|
| }
|
|
|
|
|
| portfolio.push({
|
| stock_code: stockCode,
|
| weight: weight,
|
| stock_name: '加载中...',
|
| industry: '-',
|
| price: null,
|
| price_change: null,
|
| score: null,
|
| recommendation: null,
|
| loading: true
|
| });
|
|
|
|
|
| savePortfolio();
|
|
|
|
|
| $('#addStockModal').modal('hide');
|
|
|
|
|
| $('#add-stock-form')[0].reset();
|
|
|
|
|
| fetchStockData(stockCode);
|
| }
|
|
|
|
|
| function retryFetchStockData(stockCode) {
|
| showInfo(`正在重新获取 ${stockCode} 的数据...`);
|
| fetchStockData(stockCode);
|
| }
|
|
|
|
|
| html += `
|
| <tr>
|
| <td>${stock.stock_code}</td>
|
| <td>${stockName} ${stock.stock_name === '获取失败' ?
|
| `<button class="btn btn-sm btn-link p-0 ml-2" onclick="retryFetchStockData('${stock.stock_code}')">
|
| <i class="fas fa-sync-alt"></i> 重试
|
| </button>` : ''}
|
| </td>
|
| ...
|
| `;
|
|
|
|
|
| function fetchStockData(stockCode) {
|
| const index = portfolio.findIndex(s => s.stock_code === stockCode);
|
| if (index < 0) return;
|
|
|
|
|
| portfolio[index].loading = true;
|
| savePortfolio();
|
| renderPortfolio();
|
|
|
| $.ajax({
|
| url: '/analyze',
|
| type: 'POST',
|
| contentType: 'application/json',
|
| data: JSON.stringify({
|
| stock_codes: [stockCode],
|
| market_type: 'A'
|
| }),
|
| success: function(response) {
|
|
|
| if (response.results && response.results.length > 0) {
|
| const result = response.results[0];
|
|
|
|
|
|
|
| portfolio[index].stock_name = result.stock_name || '未知';
|
| portfolio[index].industry = result.industry || '未知';
|
| portfolio[index].price = result.price || 0;
|
| portfolio[index].price_change = result.price_change || 0;
|
| portfolio[index].score = result.score || 0;
|
| portfolio[index].recommendation = result.recommendation || '-';
|
| portfolio[index].loading = false;
|
|
|
|
|
| savePortfolio();
|
|
|
|
|
| analyzePortfolio();
|
|
|
| showSuccess(`已添加 ${result.stock_name || stockCode} 到投资组合`);
|
| } else {
|
| portfolio[index].stock_name = '数据获取失败';
|
| portfolio[index].loading = false;
|
| savePortfolio();
|
| renderPortfolio();
|
| showError(`获取股票 ${stockCode} 数据失败`);
|
| }
|
| },
|
| error: function(error) {
|
| portfolio[index].stock_name = '获取失败';
|
| portfolio[index].loading = false;
|
| savePortfolio();
|
| renderPortfolio();
|
| showError(`获取股票 ${stockCode} 数据失败`);
|
| }
|
| });
|
| }
|
|
|
|
|
| function removeStock(index) {
|
| if (confirm('确定要从投资组合中移除此股票吗?')) {
|
| portfolio.splice(index, 1);
|
| savePortfolio();
|
| renderPortfolio();
|
| analyzePortfolio();
|
| }
|
| }
|
|
|
|
|
| function savePortfolio() {
|
| localStorage.setItem('portfolio', JSON.stringify(portfolio));
|
| renderPortfolio();
|
| }
|
|
|
|
|
|
|
| function analyzePortfolio() {
|
| if (portfolio.length === 0) return;
|
|
|
|
|
| let totalScore = 0;
|
| let totalWeight = 0;
|
| let industriesMap = {};
|
|
|
| portfolio.forEach(stock => {
|
| if (stock.score) {
|
| totalScore += stock.score * stock.weight;
|
| totalWeight += stock.weight;
|
|
|
|
|
| const industry = stock.industry || '其他';
|
| if (industriesMap[industry]) {
|
| industriesMap[industry] += stock.weight;
|
| } else {
|
| industriesMap[industry] = stock.weight;
|
| }
|
| }
|
| });
|
|
|
|
|
| if (totalWeight > 0) {
|
| const portfolioScore = Math.round(totalScore / totalWeight);
|
|
|
|
|
| $('#portfolio-score').text(portfolioScore);
|
|
|
|
|
| const technicalScore = Math.round(portfolioScore * 0.4);
|
| const fundamentalScore = Math.round(portfolioScore * 0.4);
|
| const capitalFlowScore = Math.round(portfolioScore * 0.2);
|
|
|
| $('#technical-score').text(technicalScore + '/40');
|
| $('#fundamental-score').text(fundamentalScore + '/40');
|
| $('#capital-flow-score').text(capitalFlowScore + '/20');
|
|
|
| $('#technical-progress').css('width', (technicalScore / 40 * 100) + '%');
|
| $('#fundamental-progress').css('width', (fundamentalScore / 40 * 100) + '%');
|
| $('#capital-flow-progress').css('width', (capitalFlowScore / 20 * 100) + '%');
|
|
|
|
|
| renderPortfolioScoreChart(portfolioScore);
|
|
|
|
|
| renderIndustryChart(industriesMap);
|
|
|
|
|
| generateRecommendations(portfolioScore);
|
| }
|
| }
|
|
|
|
|
| function renderPortfolioScoreChart(score) {
|
| const options = {
|
| series: [score],
|
| chart: {
|
| height: 150,
|
| type: 'radialBar',
|
| },
|
| plotOptions: {
|
| radialBar: {
|
| hollow: {
|
| size: '70%',
|
| },
|
| dataLabels: {
|
| show: false
|
| }
|
| }
|
| },
|
| colors: [getScoreColor(score)],
|
| stroke: {
|
| lineCap: 'round'
|
| }
|
| };
|
|
|
|
|
| $('#portfolio-score-chart').empty();
|
|
|
| const chart = new ApexCharts(document.querySelector("#portfolio-score-chart"), options);
|
| chart.render();
|
| }
|
|
|
|
|
| function renderIndustryChart(industriesMap) {
|
|
|
| const seriesData = [];
|
| const labels = [];
|
|
|
| for (const industry in industriesMap) {
|
| if (industriesMap.hasOwnProperty(industry)) {
|
| seriesData.push(industriesMap[industry]);
|
| labels.push(industry);
|
| }
|
| }
|
|
|
| const options = {
|
| series: seriesData,
|
| chart: {
|
| type: 'pie',
|
| height: 300
|
| },
|
| labels: labels,
|
| responsive: [{
|
| breakpoint: 480,
|
| options: {
|
| chart: {
|
| height: 200
|
| },
|
| legend: {
|
| position: 'bottom'
|
| }
|
| }
|
| }],
|
| tooltip: {
|
| y: {
|
| formatter: function(value) {
|
| return value + '%';
|
| }
|
| }
|
| }
|
| };
|
|
|
|
|
| $('#industry-chart').empty();
|
|
|
| const chart = new ApexCharts(document.querySelector("#industry-chart"), options);
|
| chart.render();
|
| }
|
|
|
|
|
| function generateRecommendations(portfolioScore) {
|
| let recommendations = [];
|
|
|
|
|
| if (portfolioScore >= 80) {
|
| recommendations.push({
|
| text: '您的投资组合整体评级优秀,当前市场环境下建议保持较高仓位',
|
| type: 'success'
|
| });
|
| } else if (portfolioScore >= 60) {
|
| recommendations.push({
|
| text: '您的投资组合整体评级良好,可以考虑适度增加仓位',
|
| type: 'primary'
|
| });
|
| } else if (portfolioScore >= 40) {
|
| recommendations.push({
|
| text: '您的投资组合整体评级一般,建议持币观望,等待更好的入场时机',
|
| type: 'warning'
|
| });
|
| } else {
|
| recommendations.push({
|
| text: '您的投资组合整体评级较弱,建议减仓规避风险',
|
| type: 'danger'
|
| });
|
| }
|
|
|
|
|
| const industries = {};
|
| let totalWeight = 0;
|
|
|
| portfolio.forEach(stock => {
|
| const industry = stock.industry || '其他';
|
| if (industries[industry]) {
|
| industries[industry] += stock.weight;
|
| } else {
|
| industries[industry] = stock.weight;
|
| }
|
| totalWeight += stock.weight;
|
| });
|
|
|
|
|
| let maxIndustryWeight = 0;
|
| let maxIndustry = '';
|
|
|
| for (const industry in industries) {
|
| if (industries[industry] > maxIndustryWeight) {
|
| maxIndustryWeight = industries[industry];
|
| maxIndustry = industry;
|
| }
|
| }
|
|
|
| const industryConcentration = maxIndustryWeight / totalWeight;
|
|
|
| if (industryConcentration > 0.5) {
|
| recommendations.push({
|
| text: `行业集中度较高,${maxIndustry}行业占比${Math.round(industryConcentration * 100)}%,建议适当分散投资降低非系统性风险`,
|
| type: 'warning'
|
| });
|
| }
|
|
|
|
|
| const weakStocks = portfolio.filter(stock => stock.score && stock.score < 40);
|
| if (weakStocks.length > 0) {
|
| const stockNames = weakStocks.map(s => `${s.stock_name}(${s.stock_code})`).join('、');
|
| recommendations.push({
|
| text: `以下个股评分较低,建议考虑调整:${stockNames}`,
|
| type: 'danger'
|
| });
|
| }
|
|
|
| const strongStocks = portfolio.filter(stock => stock.score && stock.score > 70);
|
| if (strongStocks.length > 0 && portfolioScore < 60) {
|
| const stockNames = strongStocks.map(s => `${s.stock_name}(${s.stock_code})`).join('、');
|
| recommendations.push({
|
| text: `以下个股表现强势,可考虑增加配置比例:${stockNames}`,
|
| type: 'success'
|
| });
|
| }
|
|
|
|
|
| let html = '';
|
| recommendations.forEach(rec => {
|
| html += `<li class="list-group-item list-group-item-${rec.type}">${rec.text}</li>`;
|
| });
|
|
|
| $('#recommendations-list').html(html);
|
| }
|
|
|
|
|
| function getScoreColor(score) {
|
| if (score >= 80) return '#28a745';
|
| if (score >= 60) return '#007bff';
|
| if (score >= 40) return '#ffc107';
|
| return '#dc3545';
|
| }
|
| </script>
|
| {% endblock %} |