|
{% 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>
|
|
</div>
|
|
<div class="card-body">
|
|
<form id="scan-form" class="row g-3">
|
|
<div class="col-md-3">
|
|
<div class="input-group">
|
|
<span class="input-group-text">选择指数</span>
|
|
<select class="form-select" id="index-selector">
|
|
<option value="">-- 选择指数 --</option>
|
|
<option value="000300">沪深300</option>
|
|
<option value="000905">中证500</option>
|
|
<option value="000852">中证1000</option>
|
|
<option value="000001">上证指数</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="input-group">
|
|
<span class="input-group-text">选择行业</span>
|
|
<select class="form-select" id="industry-selector">
|
|
<option value="">-- 选择行业 --</option>
|
|
<option value="保险">保险</option>
|
|
<option value="食品饮料">食品饮料</option>
|
|
<option value="多元金融">多元金融</option>
|
|
<option value="游戏">游戏</option>
|
|
<option value="酿酒行业">酿酒行业</option>
|
|
<option value="商业百货">商业百货</option>
|
|
<option value="证券">证券</option>
|
|
<option value="船舶制造">船舶制造</option>
|
|
<option value="家用轻工">家用轻工</option>
|
|
<option value="旅游酒店">旅游酒店</option>
|
|
<option value="美容护理">美容护理</option>
|
|
<option value="医疗服务">医疗服务</option>
|
|
<option value="软件开发">软件开发</option>
|
|
<option value="化学制药">化学制药</option>
|
|
<option value="医疗器械">医疗器械</option>
|
|
<option value="家电行业">家电行业</option>
|
|
<option value="汽车服务">汽车服务</option>
|
|
<option value="造纸印刷">造纸印刷</option>
|
|
<option value="纺织服装">纺织服装</option>
|
|
<option value="光伏设备">光伏设备</option>
|
|
<option value="房地产服务">房地产服务</option>
|
|
<option value="文化传媒">文化传媒</option>
|
|
<option value="医药商业">医药商业</option>
|
|
<option value="中药">中药</option>
|
|
<option value="专业服务">专业服务</option>
|
|
<option value="生物制品">生物制品</option>
|
|
<option value="仪器仪表">仪器仪表</option>
|
|
<option value="房地产开发">房地产开发</option>
|
|
<option value="教育">教育</option>
|
|
<option value="半导体">半导体</option>
|
|
<option value="玻璃玻纤">玻璃玻纤</option>
|
|
<option value="汽车整车">汽车整车</option>
|
|
<option value="消费电子">消费电子</option>
|
|
<option value="贸易行业">贸易行业</option>
|
|
<option value="包装材料">包装材料</option>
|
|
<option value="汽车零部件">汽车零部件</option>
|
|
<option value="电子化学品">电子化学品</option>
|
|
<option value="电子元件">电子元件</option>
|
|
<option value="装修建材">装修建材</option>
|
|
<option value="交运设备">交运设备</option>
|
|
<option value="农牧饲渔">农牧饲渔</option>
|
|
<option value="塑料制品">塑料制品</option>
|
|
<option value="珠宝首饰">珠宝首饰</option>
|
|
<option value="贵金属">贵金属</option>
|
|
<option value="非金属材料">非金属材料</option>
|
|
<option value="装修装饰">装修装饰</option>
|
|
<option value="风电设备">风电设备</option>
|
|
<option value="工程咨询服务">工程咨询服务</option>
|
|
<option value="专用设备">专用设备</option>
|
|
<option value="光学光电子">光学光电子</option>
|
|
<option value="航空机场">航空机场</option>
|
|
<option value="小金属">小金属</option>
|
|
<option value="物流行业">物流行业</option>
|
|
<option value="通用设备">通用设备</option>
|
|
<option value="计算机设备">计算机设备</option>
|
|
<option value="环保行业">环保行业</option>
|
|
<option value="航运港口">航运港口</option>
|
|
<option value="通信设备">通信设备</option>
|
|
<option value="水泥建材">水泥建材</option>
|
|
<option value="电池">电池</option>
|
|
<option value="化肥行业">化肥行业</option>
|
|
<option value="互联网服务">互联网服务</option>
|
|
<option value="工程建设">工程建设</option>
|
|
<option value="橡胶制品">橡胶制品</option>
|
|
<option value="化学原料">化学原料</option>
|
|
<option value="化纤行业">化纤行业</option>
|
|
<option value="农药兽药">农药兽药</option>
|
|
<option value="化学制品">化学制品</option>
|
|
<option value="能源金属">能源金属</option>
|
|
<option value="有色金属">有色金属</option>
|
|
<option value="采掘行业">采掘行业</option>
|
|
<option value="燃气">燃气</option>
|
|
<option value="综合行业">综合行业</option>
|
|
<option value="工程机械">工程机械</option>
|
|
<option value="银行">银行</option>
|
|
<option value="铁路公路">铁路公路</option>
|
|
<option value="石油行业">石油行业</option>
|
|
<option value="公用事业">公用事业</option>
|
|
<option value="电机">电机</option>
|
|
<option value="通信服务">通信服务</option>
|
|
<option value="钢铁行业">钢铁行业</option>
|
|
<option value="电力行业">电力行业</option>
|
|
<option value="电网设备">电网设备</option>
|
|
<option value="煤炭行业">煤炭行业</option>
|
|
<option value="电源设备">电源设备</option>
|
|
<option value="航天航空">航天航空</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="input-group">
|
|
<span class="input-group-text">自定义股票</span>
|
|
<input type="text" class="form-control" id="custom-stocks" placeholder="多个股票代码用逗号分隔">
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="input-group">
|
|
<span class="input-group-text">最低分数</span>
|
|
<input type="number" class="form-control" id="min-score" value="60" min="0" max="100">
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="fas fa-search"></i> 扫描
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
<div>
|
|
<span class="badge bg-primary ms-2" id="result-count">0</span>
|
|
<button class="btn btn-sm btn-outline-primary ms-2" id="export-btn" style="display: none;">
|
|
<i class="fas fa-download"></i> 导出结果
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="scan-loading" class="text-center py-5" style="display: none;">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
<p class="mt-2" id="scan-message">正在扫描市场,请稍候...</p>
|
|
<div class="progress mt-3" style="height: 5px;">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%"></div>
|
|
</div>
|
|
<button id="cancel-scan-btn" class="btn btn-outline-secondary mt-3">
|
|
<i class="fas fa-times"></i> 取消扫描
|
|
</button>
|
|
</div>
|
|
|
|
|
|
<div id="scan-error-retry" class="text-center mt-3" style="display: none;">
|
|
<button id="scan-retry-button" class="btn btn-primary mt-2">
|
|
<i class="fas fa-sync-alt"></i> 重试扫描
|
|
</button>
|
|
<p class="text-muted small mt-2">
|
|
已超负载
|
|
</p>
|
|
</div>
|
|
|
|
<div id="scan-results">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>代码</th>
|
|
<th>名称</th>
|
|
<th>行业</th>
|
|
<th>得分</th>
|
|
<th>价格</th>
|
|
<th>涨跌幅</th>
|
|
<th>RSI</th>
|
|
<th>MA趋势</th>
|
|
<th>成交量</th>
|
|
<th>建议</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="results-table">
|
|
<tr>
|
|
<td colspan="11" class="text-center">暂无数据,请开始扫描</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
$(document).ready(function() {
|
|
|
|
$('#scan-form').submit(function(e) {
|
|
e.preventDefault();
|
|
|
|
|
|
let stockList = [];
|
|
|
|
|
|
const indexCode = $('#index-selector').val();
|
|
if (indexCode) {
|
|
fetchIndexStocks(indexCode);
|
|
return;
|
|
}
|
|
|
|
|
|
const industry = $('#industry-selector').val();
|
|
if (industry) {
|
|
fetchIndustryStocks(industry);
|
|
return;
|
|
}
|
|
|
|
|
|
const customStocks = $('#custom-stocks').val().trim();
|
|
if (customStocks) {
|
|
stockList = customStocks.split(',').map(s => s.trim());
|
|
scanMarket(stockList);
|
|
} else {
|
|
showError('请至少选择一种方式获取股票列表');
|
|
}
|
|
});
|
|
|
|
|
|
$('#index-selector').change(function() {
|
|
if ($(this).val()) {
|
|
$('#industry-selector').val('');
|
|
}
|
|
});
|
|
|
|
|
|
$('#industry-selector').change(function() {
|
|
if ($(this).val()) {
|
|
$('#index-selector').val('');
|
|
}
|
|
});
|
|
|
|
|
|
$('#export-btn').click(function() {
|
|
exportToCSV();
|
|
});
|
|
|
|
|
|
function fetchIndexStocks(indexCode) {
|
|
$('#scan-loading').show();
|
|
$('#scan-results').hide();
|
|
|
|
$.ajax({
|
|
url: `/api/index_stocks?index_code=${indexCode}`,
|
|
type: 'GET',
|
|
dataType: 'json',
|
|
success: function(response) {
|
|
const stockList = response.stock_list;
|
|
if (stockList && stockList.length > 0) {
|
|
|
|
window.lastScanList = stockList;
|
|
|
|
scanMarket(stockList);
|
|
} else {
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
showError('获取指数成分股失败,或成分股列表为空');
|
|
}
|
|
},
|
|
error: function(error) {
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
showError('获取指数成分股失败: ' + (error.responseJSON ? error.responseJSON.error : error.statusText));
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function fetchIndustryStocks(industry) {
|
|
$('#scan-loading').show();
|
|
$('#scan-results').hide();
|
|
|
|
$.ajax({
|
|
url: `/api/industry_stocks?industry=${encodeURIComponent(industry)}`,
|
|
type: 'GET',
|
|
dataType: 'json',
|
|
success: function(response) {
|
|
const stockList = response.stock_list;
|
|
if (stockList && stockList.length > 0) {
|
|
|
|
window.lastScanList = stockList;
|
|
|
|
scanMarket(stockList);
|
|
} else {
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
showError('获取行业成分股失败,或成分股列表为空');
|
|
}
|
|
},
|
|
error: function(error) {
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
showError('获取行业成分股失败: ' + (error.responseJSON ? error.responseJSON.error : error.statusText));
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function scanMarket(stockList) {
|
|
$('#scan-loading').show();
|
|
$('#scan-results').hide();
|
|
$('#scan-error-retry').hide();
|
|
|
|
|
|
let processingTime = 0;
|
|
let stockCount = stockList.length;
|
|
|
|
|
|
window.lastScanList = stockList;
|
|
|
|
|
|
$('#scan-message').html(`正在准备扫描${stockCount}只股票,请稍候...`);
|
|
|
|
const minScore = parseInt($('#min-score').val() || 60);
|
|
|
|
|
|
$.ajax({
|
|
url: '/api/start_market_scan',
|
|
type: 'POST',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify({
|
|
stock_list: stockList,
|
|
min_score: minScore,
|
|
market_type: 'A'
|
|
}),
|
|
success: function(response) {
|
|
const taskId = response.task_id;
|
|
|
|
if (!taskId) {
|
|
showError('启动扫描任务失败:未获取到任务ID');
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
$('#scan-error-retry').show();
|
|
return;
|
|
}
|
|
|
|
|
|
pollScanStatus(taskId, processingTime);
|
|
},
|
|
error: function(xhr, status, error) {
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
|
|
let errorMsg = '启动扫描任务失败';
|
|
if (xhr.responseJSON && xhr.responseJSON.error) {
|
|
errorMsg += ': ' + xhr.responseJSON.error;
|
|
} else if (error) {
|
|
errorMsg += ': ' + error;
|
|
}
|
|
|
|
showError(errorMsg);
|
|
$('#scan-error-retry').show();
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function pollScanStatus(taskId, startTime) {
|
|
let elapsedTime = startTime || 0;
|
|
let pollInterval;
|
|
|
|
|
|
checkStatus();
|
|
|
|
function checkStatus() {
|
|
$.ajax({
|
|
url: `/api/scan_status/${taskId}`,
|
|
type: 'GET',
|
|
success: function(response) {
|
|
|
|
elapsedTime++;
|
|
const progress = response.progress || 0;
|
|
|
|
|
|
$('#scan-message').html(`正在扫描市场...<br>
|
|
进度: ${progress}% 完成<br>
|
|
已处理 ${Math.round(response.total * progress / 100)} / ${response.total} 只股票<br>
|
|
耗时: ${elapsedTime}秒`);
|
|
|
|
|
|
if (response.status === 'completed') {
|
|
|
|
clearInterval(pollInterval);
|
|
|
|
|
|
renderResults(response.result || []);
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
|
|
|
|
if (!response.result || response.result.length === 0) {
|
|
$('#results-table').html('<tr><td colspan="11" class="text-center">未找到符合条件的股票</td></tr>');
|
|
$('#result-count').text('0');
|
|
$('#export-btn').hide();
|
|
}
|
|
|
|
} else if (response.status === 'failed') {
|
|
|
|
clearInterval(pollInterval);
|
|
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
|
|
showError('扫描任务失败: ' + (response.error || '未知错误'));
|
|
$('#scan-error-retry').show();
|
|
|
|
} else {
|
|
|
|
|
|
if (!pollInterval) {
|
|
pollInterval = setInterval(checkStatus, 2000);
|
|
}
|
|
}
|
|
},
|
|
error: function(xhr, status, error) {
|
|
|
|
|
|
if (!pollInterval) {
|
|
pollInterval = setInterval(checkStatus, 3000);
|
|
}
|
|
|
|
|
|
$('#scan-message').html(`正在扫描市场...<br>
|
|
无法获取最新进度<br>
|
|
耗时: ${elapsedTime}秒`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
function cancelScan(taskId) {
|
|
$.ajax({
|
|
url: `/api/cancel_scan/${taskId}`,
|
|
type: 'POST',
|
|
success: function(response) {
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
showError('扫描任务已取消');
|
|
$('#scan-error-retry').show();
|
|
},
|
|
error: function(xhr, status, error) {
|
|
console.error('取消扫描任务失败:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function renderResults(results) {
|
|
if (!results || results.length === 0) {
|
|
$('#results-table').html('<tr><td colspan="11" class="text-center">未找到符合条件的股票</td></tr>');
|
|
$('#result-count').text('0');
|
|
$('#export-btn').hide();
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
results.forEach(result => {
|
|
|
|
const scoreClass = getScoreColorClass(result.score);
|
|
|
|
|
|
const maTrendClass = getTrendColorClass(result.ma_trend);
|
|
const maTrendIcon = getTrendIcon(result.ma_trend);
|
|
|
|
|
|
const priceChangeClass = result.price_change >= 0 ? 'trend-up' : 'trend-down';
|
|
const priceChangeIcon = result.price_change >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
|
|
|
html += `
|
|
<tr>
|
|
<td>${result.stock_code}</td>
|
|
<td>${result.stock_name || '未知'}</td>
|
|
<td>${result.industry || '-'}</td>
|
|
<td><span class="badge ${scoreClass}">${result.score}</span></td>
|
|
<td>${formatNumber(result.price)}</td>
|
|
<td class="${priceChangeClass}">${priceChangeIcon} ${formatPercent(result.price_change)}</td>
|
|
<td>${formatNumber(result.rsi)}</td>
|
|
<td class="${maTrendClass}">${maTrendIcon} ${result.ma_trend}</td>
|
|
<td>${result.volume_status}</td>
|
|
<td>${result.recommendation}</td>
|
|
<td>
|
|
<a href="/stock_detail/${result.stock_code}" class="btn btn-sm btn-primary">
|
|
<i class="fas fa-chart-line"></i> 详情
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
$('#results-table').html(html);
|
|
$('#result-count').text(results.length);
|
|
$('#export-btn').show();
|
|
}
|
|
|
|
|
|
function exportToCSV() {
|
|
|
|
const table = document.querySelector('#scan-results table');
|
|
let csv = [];
|
|
let rows = table.querySelectorAll('tr');
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
let row = [], cols = rows[i].querySelectorAll('td, th');
|
|
|
|
for (let j = 0; j < cols.length - 1; j++) {
|
|
|
|
let text = cols[j].innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/,/g, ',');
|
|
row.push(text);
|
|
}
|
|
|
|
csv.push(row.join(','));
|
|
}
|
|
|
|
|
|
const csvString = csv.join('\n');
|
|
const filename = '市场扫描结果_' + new Date().toISOString().slice(0, 10) + '.csv';
|
|
|
|
const blob = new Blob(['\uFEFF' + csvString], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = filename;
|
|
|
|
link.style.display = 'none';
|
|
document.body.appendChild(link);
|
|
|
|
link.click();
|
|
|
|
document.body.removeChild(link);
|
|
}
|
|
});
|
|
|
|
|
|
|
|
let currentTaskId = null;
|
|
|
|
|
|
$('#cancel-scan-btn').click(function() {
|
|
if (currentTaskId) {
|
|
cancelScan(currentTaskId);
|
|
} else {
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
}
|
|
});
|
|
|
|
|
|
function handleStartSuccess(response) {
|
|
const taskId = response.task_id;
|
|
currentTaskId = taskId;
|
|
|
|
if (!taskId) {
|
|
showError('启动扫描任务失败:未获取到任务ID');
|
|
$('#scan-loading').hide();
|
|
$('#scan-results').show();
|
|
$('#scan-error-retry').show();
|
|
return;
|
|
}
|
|
|
|
|
|
pollScanStatus(taskId, 0);
|
|
}
|
|
|
|
</script>
|
|
{% endblock %}
|
|
|