- Introduced a new boundary utilization score to measure price proximity to triangle boundaries, improving the accuracy of strength assessments. - Updated scoring weights to incorporate boundary utilization, adjusting the contributions of convergence, volume, and fitting scores. - Added detailed chart mode in the stock viewer, allowing users to toggle between standard and detailed views with additional metrics displayed. - Enhanced documentation to reflect new features, including usage instructions for the boundary utilization score and detailed chart mode. - Improved error handling in the stock viewer for better user experience.
1560 lines
52 KiB
Python
1560 lines
52 KiB
Python
"""
|
||
生成包含内嵌数据的股票查看器HTML
|
||
|
||
用法:
|
||
python scripts/generate_stock_viewer.py
|
||
python scripts/generate_stock_viewer.py --date 20260120
|
||
python scripts/generate_stock_viewer.py --all-stocks # 显示所有108只股票
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import os
|
||
import json
|
||
import sys
|
||
import pickle
|
||
import numpy as np
|
||
|
||
def load_all_stocks_list(data_dir: str) -> tuple:
|
||
"""从close.pkl加载所有股票列表"""
|
||
# 创建假模块以加载pickle
|
||
sys.modules['model'] = type('FakeModule', (), {'ndarray': np.ndarray, '__path__': []})()
|
||
sys.modules['model.index_info'] = sys.modules['model']
|
||
|
||
close_path = os.path.join(data_dir, 'close.pkl')
|
||
with open(close_path, 'rb') as f:
|
||
data = pickle.load(f)
|
||
|
||
return data['tkrs'], data['tkrs_name']
|
||
|
||
def load_stock_data(csv_path: str, target_date: int = None, all_stocks_mode: bool = False, data_dir: str = 'data') -> tuple:
|
||
"""从CSV加载股票数据"""
|
||
stocks_map = {}
|
||
max_date = 0
|
||
|
||
with open(csv_path, 'r', encoding='utf-8-sig') as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
try:
|
||
date = int(row.get('date', '0'))
|
||
if date > max_date:
|
||
max_date = date
|
||
except:
|
||
continue
|
||
|
||
# 使用指定日期或最新日期
|
||
use_date = target_date if target_date else max_date
|
||
|
||
# 从CSV读取有强度分的股票
|
||
with open(csv_path, 'r', encoding='utf-8-sig') as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
try:
|
||
date = int(row.get('date', '0'))
|
||
if date != use_date:
|
||
continue
|
||
|
||
stock_code = row.get('stock_code', '')
|
||
stock = {
|
||
'idx': int(row.get('stock_idx', '0')),
|
||
'code': stock_code,
|
||
'name': row.get('stock_name', ''),
|
||
'strengthUp': float(row.get('breakout_strength_up', '0')),
|
||
'strengthDown': float(row.get('breakout_strength_down', '0')),
|
||
'direction': row.get('breakout_dir', ''),
|
||
'widthRatio': float(row.get('width_ratio', '0')),
|
||
'touchesUpper': int(row.get('touches_upper', '0')),
|
||
'touchesLower': int(row.get('touches_lower', '0')),
|
||
'volumeConfirmed': row.get('volume_confirmed', ''),
|
||
'boundaryUtilization': float(row.get('boundary_utilization', '0')),
|
||
'date': date,
|
||
'hasTriangle': True # 标记为有三角形形态
|
||
}
|
||
|
||
stock['strength'] = max(stock['strengthUp'], stock['strengthDown'])
|
||
|
||
# 清理文件名中的非法字符
|
||
clean_name = stock['name'].replace('*', '').replace('?', '').replace('"', '').replace('<', '').replace('>', '').replace('|', '').replace(':', '').replace('/', '').replace('\\', '')
|
||
stock['chartPath'] = f"charts/{date}_{stock_code}_{clean_name}.png"
|
||
stock['chartPathDetail'] = f"charts/{date}_{stock_code}_{clean_name}_detail.png"
|
||
|
||
if stock_code not in stocks_map or stocks_map[stock_code]['strength'] < stock['strength']:
|
||
stocks_map[stock_code] = stock
|
||
|
||
except Exception as e:
|
||
continue
|
||
|
||
# 如果是all_stocks模式,添加所有股票
|
||
if all_stocks_mode:
|
||
all_codes, all_names = load_all_stocks_list(data_dir)
|
||
for idx, (code, name) in enumerate(zip(all_codes, all_names)):
|
||
if code not in stocks_map:
|
||
# 没有三角形形态的股票,强度分为0
|
||
clean_name = name.replace('*', '').replace('?', '').replace('"', '').replace('<', '').replace('>', '').replace('|', '').replace(':', '').replace('/', '').replace('\\', '')
|
||
stocks_map[code] = {
|
||
'idx': idx,
|
||
'code': code,
|
||
'name': name,
|
||
'strengthUp': 0.0,
|
||
'strengthDown': 0.0,
|
||
'strength': 0.0,
|
||
'direction': 'none',
|
||
'widthRatio': 0.0,
|
||
'touchesUpper': 0,
|
||
'touchesLower': 0,
|
||
'volumeConfirmed': '',
|
||
'boundaryUtilization': 0.0,
|
||
'date': use_date,
|
||
'chartPath': f"charts/{use_date}_{code}_{clean_name}.png",
|
||
'chartPathDetail': f"charts/{use_date}_{code}_{clean_name}_detail.png",
|
||
'hasTriangle': False # 标记为无三角形形态
|
||
}
|
||
|
||
stocks = list(stocks_map.values())
|
||
stocks.sort(key=lambda x: x['strength'], reverse=True)
|
||
|
||
return stocks, use_date
|
||
|
||
def generate_html(stocks: list, date: int, output_path: str):
|
||
"""生成包含数据的HTML"""
|
||
|
||
html_template = '''<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>收敛三角形强度分选股系统</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg-primary: #0a0e27;
|
||
--bg-secondary: #111830;
|
||
--bg-card: #1a2238;
|
||
--bg-card-hover: #242d48;
|
||
--accent-primary: #00d4aa;
|
||
--accent-secondary: #7b68ee;
|
||
--accent-warm: #ff6b6b;
|
||
--accent-cool: #4ecdc4;
|
||
--accent-gold: #ffd93d;
|
||
--text-primary: #e8eaf0;
|
||
--text-secondary: #8b92a8;
|
||
--text-muted: #5a6178;
|
||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||
--border-accent: rgba(0, 212, 170, 0.3);
|
||
--shadow-glow: 0 0 40px rgba(0, 212, 170, 0.15);
|
||
--shadow-card: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
--transition-fast: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
--transition-smooth: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Noto Sans SC', sans-serif;
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
min-height: 100vh;
|
||
line-height: 1.6;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* Animated Background */
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background:
|
||
radial-gradient(ellipse at 20% 20%, rgba(123, 104, 238, 0.08) 0%, transparent 50%),
|
||
radial-gradient(ellipse at 80% 80%, rgba(0, 212, 170, 0.06) 0%, transparent 50%),
|
||
radial-gradient(ellipse at 50% 50%, rgba(255, 107, 107, 0.04) 0%, transparent 70%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
/* Noise Texture Overlay */
|
||
body::after {
|
||
content: '';
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||
opacity: 0.03;
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
.app-container {
|
||
position: relative;
|
||
z-index: 2;
|
||
max-width: 1680px;
|
||
margin: 0 auto;
|
||
padding: 24px;
|
||
}
|
||
|
||
/* Header Section */
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 28px 32px;
|
||
margin-bottom: 24px;
|
||
background: linear-gradient(135deg, rgba(26, 34, 56, 0.9) 0%, rgba(17, 24, 48, 0.95) 100%);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 16px;
|
||
backdrop-filter: blur(20px);
|
||
box-shadow: var(--shadow-card);
|
||
}
|
||
|
||
.header-title {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.header-title h1 {
|
||
font-size: 26px;
|
||
font-weight: 700;
|
||
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-cool) 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
letter-spacing: -0.3px;
|
||
}
|
||
|
||
.header-meta {
|
||
display: flex;
|
||
gap: 20px;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.header-meta span {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.header-meta span::before {
|
||
content: '';
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--accent-primary);
|
||
box-shadow: 0 0 10px var(--accent-primary);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.6; transform: scale(1.2); }
|
||
}
|
||
|
||
/* Control Panel */
|
||
.control-panel {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 16px;
|
||
padding: 24px 32px;
|
||
margin-bottom: 24px;
|
||
box-shadow: var(--shadow-card);
|
||
}
|
||
|
||
.filter-section {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 32px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.filter-label {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
min-width: 100px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.slider-wrapper {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.slider-track {
|
||
flex: 1;
|
||
height: 6px;
|
||
background: var(--bg-card);
|
||
border-radius: 3px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.slider-track::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
height: 100%;
|
||
width: var(--slider-value, 0%);
|
||
background: linear-gradient(90deg, var(--accent-secondary) 0%, var(--accent-primary) 100%);
|
||
border-radius: 3px;
|
||
transition: width 0.1s linear;
|
||
}
|
||
|
||
input[type="range"] {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 100%;
|
||
height: 6px;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
|
||
input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--accent-primary);
|
||
cursor: pointer;
|
||
box-shadow: 0 0 20px rgba(0, 212, 170, 0.5);
|
||
border: 3px solid var(--bg-primary);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
input[type="range"]::-webkit-slider-thumb:hover {
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
input[type="range"]::-moz-range-thumb {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--accent-primary);
|
||
cursor: pointer;
|
||
box-shadow: 0 0 20px rgba(0, 212, 170, 0.5);
|
||
border: 3px solid var(--bg-primary);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
input[type="range"]::-moz-range-thumb:hover {
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
.slider-value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: var(--accent-primary);
|
||
min-width: 80px;
|
||
text-align: right;
|
||
}
|
||
|
||
/* Stats Grid */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
padding-top: 20px;
|
||
border-top: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.stat-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 12px;
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
transition: all var(--transition-fast);
|
||
}
|
||
|
||
.stat-card:hover {
|
||
border-color: var(--border-accent);
|
||
background: var(--bg-card-hover);
|
||
}
|
||
|
||
.stat-icon {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 20px;
|
||
background: rgba(0, 212, 170, 0.1);
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
.stat-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* Stocks Grid */
|
||
.stocks-section {
|
||
min-height: 400px;
|
||
}
|
||
|
||
.stocks-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.stock-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
transition: all var(--transition-smooth);
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
|
||
.stock-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 3px;
|
||
height: 100%;
|
||
background: linear-gradient(180deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
|
||
opacity: 0;
|
||
transition: opacity var(--transition-fast);
|
||
}
|
||
|
||
.stock-card:hover {
|
||
transform: translateY(-4px);
|
||
border-color: var(--border-accent);
|
||
box-shadow: var(--shadow-glow), var(--shadow-card);
|
||
}
|
||
|
||
.stock-card:hover::before {
|
||
opacity: 1;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 18px 20px;
|
||
background: linear-gradient(135deg, rgba(0, 212, 170, 0.08) 0%, rgba(123, 104, 238, 0.05) 100%);
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.stock-identity {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.stock-name {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.stock-code {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
background: rgba(0, 212, 170, 0.15);
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
display: inline-block;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.strength-badge {
|
||
text-align: right;
|
||
}
|
||
|
||
.strength-value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-cool) 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
.strength-label {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.card-body {
|
||
padding: 16px 20px;
|
||
}
|
||
|
||
.metrics-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.metric-item {
|
||
background: var(--bg-secondary);
|
||
border-radius: 10px;
|
||
padding: 10px 14px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.metric-label {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.metric-value {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.metric-value.direction-up {
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
.metric-value.direction-down {
|
||
color: var(--accent-warm);
|
||
}
|
||
|
||
.chart-container {
|
||
position: relative;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.stock-chart {
|
||
width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
transition: transform var(--transition-smooth);
|
||
}
|
||
|
||
.stock-card:hover .stock-chart {
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.chart-overlay {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
background: linear-gradient(transparent, rgba(10, 14, 39, 0.9));
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
/* Empty State */
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 80px 20px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.empty-state-icon {
|
||
font-size: 64px;
|
||
margin-bottom: 16px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.empty-state h3 {
|
||
font-size: 20px;
|
||
color: var(--text-primary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* Modal */
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
z-index: 1000;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(10, 14, 39, 0.95);
|
||
backdrop-filter: blur(10px);
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px;
|
||
}
|
||
|
||
.modal.show {
|
||
display: flex;
|
||
animation: fadeIn 0.2s ease-out;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.modal-content {
|
||
max-width: 90%;
|
||
max-height: 90%;
|
||
position: relative;
|
||
}
|
||
|
||
.modal-content img {
|
||
max-width: 100%;
|
||
max-height: 85vh;
|
||
border-radius: 16px;
|
||
box-shadow: var(--shadow-glow), var(--shadow-card);
|
||
border: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.close-modal {
|
||
position: absolute;
|
||
top: -48px;
|
||
right: 0;
|
||
color: var(--text-secondary);
|
||
font-size: 36px;
|
||
font-weight: 300;
|
||
cursor: pointer;
|
||
background: none;
|
||
border: none;
|
||
transition: color var(--transition-fast);
|
||
line-height: 1;
|
||
}
|
||
|
||
.close-modal:hover {
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
/* Animations */
|
||
@keyframes slideIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.stock-card {
|
||
animation: slideIn 0.4s ease-out backwards;
|
||
}
|
||
|
||
.stock-card:nth-child(1) { animation-delay: 0.05s; }
|
||
.stock-card:nth-child(2) { animation-delay: 0.1s; }
|
||
.stock-card:nth-child(3) { animation-delay: 0.15s; }
|
||
.stock-card:nth-child(4) { animation-delay: 0.2s; }
|
||
.stock-card:nth-child(5) { animation-delay: 0.25s; }
|
||
.stock-card:nth-child(6) { animation-delay: 0.3s; }
|
||
|
||
/* Responsive */
|
||
@media (max-width: 768px) {
|
||
.app-container {
|
||
padding: 16px;
|
||
}
|
||
|
||
.header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
padding: 20px;
|
||
}
|
||
|
||
.filter-section {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 16px;
|
||
}
|
||
|
||
.filter-label {
|
||
min-width: auto;
|
||
}
|
||
|
||
.stats-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.stocks-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* Filter Row - Top bar with search and sort */
|
||
.filter-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* Search Box */
|
||
.search-box {
|
||
position: relative;
|
||
flex: 1;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.search-box input {
|
||
width: 100%;
|
||
padding: 12px 16px 12px 44px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 10px;
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
font-family: 'Noto Sans SC', sans-serif;
|
||
transition: all var(--transition-fast);
|
||
}
|
||
|
||
.search-box input:focus {
|
||
outline: none;
|
||
border-color: var(--accent-primary);
|
||
box-shadow: 0 0 0 3px rgba(0, 212, 170, 0.1);
|
||
}
|
||
|
||
.search-box::before {
|
||
content: '🔍';
|
||
position: absolute;
|
||
left: 14px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 16px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.search-box .clear-search {
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 20px;
|
||
height: 20px;
|
||
background: var(--bg-card-hover);
|
||
border: none;
|
||
border-radius: 50%;
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all var(--transition-fast);
|
||
}
|
||
|
||
.search-box .clear-search:hover {
|
||
background: var(--accent-warm);
|
||
color: white;
|
||
}
|
||
|
||
.search-box.has-value .clear-search {
|
||
display: flex;
|
||
}
|
||
|
||
/* Sort Select */
|
||
.sort-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.sort-label {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.sort-select {
|
||
padding: 10px 16px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 10px;
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
font-family: 'Noto Sans SC', sans-serif;
|
||
cursor: pointer;
|
||
transition: all var(--transition-fast);
|
||
}
|
||
|
||
.sort-select:hover {
|
||
border-color: var(--border-accent);
|
||
}
|
||
|
||
.sort-select:focus {
|
||
outline: none;
|
||
border-color: var(--accent-primary);
|
||
box-shadow: 0 0 0 3px rgba(0, 212, 170, 0.1);
|
||
}
|
||
|
||
.sort-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 10px 14px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 10px;
|
||
color: var(--text-secondary);
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
transition: all var(--transition-fast);
|
||
}
|
||
|
||
.sort-toggle:hover {
|
||
border-color: var(--border-accent);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.sort-toggle.active {
|
||
background: rgba(0, 212, 170, 0.1);
|
||
border-color: var(--accent-primary);
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
/* Reset Button */
|
||
.reset-btn {
|
||
padding: 10px 20px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 10px;
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
font-family: 'Noto Sans SC', sans-serif;
|
||
cursor: pointer;
|
||
transition: all var(--transition-fast);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.reset-btn:hover {
|
||
border-color: var(--accent-warm);
|
||
color: var(--accent-warm);
|
||
}
|
||
|
||
/* Detail Toggle */
|
||
.detail-toggle {
|
||
padding: 10px 18px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 10px;
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
font-family: 'Noto Sans SC', sans-serif;
|
||
cursor: pointer;
|
||
transition: all var(--transition-fast);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.detail-toggle:hover {
|
||
border-color: var(--accent-primary);
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
.detail-toggle.active {
|
||
background: rgba(0, 212, 170, 0.1);
|
||
border-color: var(--accent-primary);
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
/* Filter Chips */
|
||
.filter-chips {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-chip {
|
||
padding: 8px 16px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 20px;
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all var(--transition-fast);
|
||
user-select: none;
|
||
}
|
||
|
||
.filter-chip:hover {
|
||
border-color: var(--border-accent);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.filter-chip.active {
|
||
background: rgba(0, 212, 170, 0.15);
|
||
border-color: var(--accent-primary);
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
.filter-chip[data-value="up"].active {
|
||
background: rgba(0, 212, 170, 0.15);
|
||
border-color: var(--accent-primary);
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
.filter-chip[data-value="down"].active {
|
||
background: rgba(255, 107, 107, 0.15);
|
||
border-color: var(--accent-warm);
|
||
color: var(--accent-warm);
|
||
}
|
||
|
||
/* Dual Slider for Width Ratio */
|
||
.dual-slider-section {
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.dual-slider-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.dual-slider-label {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.dual-slider-values {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 14px;
|
||
color: var(--accent-primary);
|
||
}
|
||
|
||
.dual-slider {
|
||
position: relative;
|
||
height: 30px;
|
||
}
|
||
|
||
.dual-slider-track {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 0;
|
||
right: 0;
|
||
height: 6px;
|
||
transform: translateY(-50%);
|
||
background: var(--bg-card);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.dual-slider-range {
|
||
position: absolute;
|
||
top: 50%;
|
||
height: 6px;
|
||
transform: translateY(-50%);
|
||
background: linear-gradient(90deg, var(--accent-secondary) 0%, var(--accent-primary) 100%);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.dual-slider input[type="range"] {
|
||
position: absolute;
|
||
pointer-events: none;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 100%;
|
||
height: 6px;
|
||
background: transparent;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
z-index: 2;
|
||
}
|
||
|
||
.dual-slider input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
pointer-events: auto;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--accent-primary);
|
||
cursor: pointer;
|
||
box-shadow: 0 0 20px rgba(0, 212, 170, 0.5);
|
||
border: 3px solid var(--bg-primary);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.dual-slider input[type="range"]::-webkit-slider-thumb:hover {
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
.dual-slider input[type="range"]::-moz-range-thumb {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--accent-primary);
|
||
cursor: pointer;
|
||
box-shadow: 0 0 20px rgba(0, 212, 170, 0.5);
|
||
border: 3px solid var(--bg-primary);
|
||
transition: transform 0.2s;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.dual-slider input[type="range"]::-moz-range-thumb:hover {
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar {
|
||
width: 10px;
|
||
height: 10px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: var(--bg-secondary);
|
||
border-radius: 5px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--accent-primary);
|
||
border-radius: 5px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: var(--accent-cool);
|
||
}
|
||
</style>
|
||
<script>
|
||
// 工具函数(需要在页面加载前定义,因为HTML元素的onerror会直接调用)
|
||
function handleModalImageError(img) {
|
||
const fallbackSrc = img.dataset.fallbackSrc;
|
||
if (fallbackSrc && img.src !== fallbackSrc && img.dataset.fallbackTried !== 'true') {
|
||
img.dataset.fallbackTried = 'true';
|
||
img.src = fallbackSrc;
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<div class="app-container">
|
||
<header class="header">
|
||
<div class="header-title">
|
||
<h1>收敛三角形选股系统</h1>
|
||
<div class="header-meta">
|
||
<span>数据日期: DATA_DATE</span>
|
||
<span>监控股票: TOTAL_STOCKS</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="control-panel">
|
||
<!-- Top Filter Row: Search, Sort, Reset -->
|
||
<div class="filter-row">
|
||
<div class="search-box">
|
||
<input type="text" id="searchInput" placeholder="搜索股票代码或名称...">
|
||
<button class="clear-search" id="clearSearch">×</button>
|
||
</div>
|
||
<div class="sort-wrapper">
|
||
<span class="sort-label">排序</span>
|
||
<select class="sort-select" id="sortSelect">
|
||
<option value="strength">按强度分</option>
|
||
<option value="widthRatio">按宽度比</option>
|
||
<option value="touches">按触碰次数</option>
|
||
</select>
|
||
<button class="sort-toggle active" id="sortOrder" title="切换排序顺序">↓</button>
|
||
</div>
|
||
<button class="detail-toggle" id="detailToggle" title="切换详细图">
|
||
详细图: 关
|
||
</button>
|
||
<button class="reset-btn" id="resetBtn">
|
||
<span>↺</span> 重置筛选
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Direction Filter -->
|
||
<div class="filter-section">
|
||
<label class="filter-label">突破方向</label>
|
||
<div class="filter-chips" id="directionFilter">
|
||
<div class="filter-chip active" data-value="all">全部</div>
|
||
<div class="filter-chip" data-value="up">↑ 向上</div>
|
||
<div class="filter-chip" data-value="down">↓ 向下</div>
|
||
<div class="filter-chip" data-value="none">— 无</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Volume Filter -->
|
||
<div class="filter-section">
|
||
<label class="filter-label">放量确认</label>
|
||
<div class="filter-chips" id="volumeFilter">
|
||
<div class="filter-chip active" data-value="all">全部</div>
|
||
<div class="filter-chip" data-value="true">✓ 已确认</div>
|
||
<div class="filter-chip" data-value="false">✗ 未确认</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Strength Slider -->
|
||
<div class="filter-section">
|
||
<label class="filter-label">强度阈值</label>
|
||
<div class="slider-wrapper">
|
||
<div class="slider-track" id="sliderTrack"></div>
|
||
<input type="range" id="strengthSlider" min="0" max="1" step="0.01" value="0">
|
||
<div class="slider-value" id="strengthValue">0.00</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-icon">📊</div>
|
||
<div class="stat-info">
|
||
<div class="stat-label">Total</div>
|
||
<div class="stat-value" id="totalStocks">0</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon">✨</div>
|
||
<div class="stat-info">
|
||
<div class="stat-label">Showing</div>
|
||
<div class="stat-value" id="displayedStocks">0</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon">⚡</div>
|
||
<div class="stat-info">
|
||
<div class="stat-label">Average</div>
|
||
<div class="stat-value" id="avgStrength">0.00</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="stocks-section">
|
||
<div id="stocksGrid" class="stocks-grid"></div>
|
||
<div id="emptyState" class="empty-state" style="display: none;">
|
||
<div class="empty-state-icon">📭</div>
|
||
<h3>没有符合条件的股票</h3>
|
||
<p>请降低强度分阈值以查看更多股票</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="imageModal" class="modal">
|
||
<div class="modal-content">
|
||
<button class="close-modal" onclick="closeModal()">×</button>
|
||
<img id="modalImage" src="" alt="股票图表" onerror="handleModalImageError(this)">
|
||
</div>
|
||
</div>
|
||
<script>
|
||
// 内嵌数据
|
||
const allStocks = STOCK_DATA;
|
||
let currentThreshold = 0;
|
||
let showDetailCharts = false;
|
||
|
||
// 筛选和排序状态
|
||
let filters = {
|
||
direction: 'all', // 'all', 'up', 'down', 'none'
|
||
volume: 'all' // 'all', 'true', 'false'
|
||
};
|
||
let sortBy = 'strength'; // 'strength', 'widthRatio', 'touches'
|
||
let sortOrder = 'desc'; // 'desc', 'asc'
|
||
let searchQuery = '';
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
setupEventListeners();
|
||
filterAndDisplayStocks();
|
||
});
|
||
|
||
function setupEventListeners() {
|
||
// 强度滑块
|
||
const slider = document.getElementById('strengthSlider');
|
||
const sliderTrack = document.getElementById('sliderTrack');
|
||
const valueDisplay = document.getElementById('strengthValue');
|
||
|
||
slider.addEventListener('input', function() {
|
||
currentThreshold = parseFloat(this.value);
|
||
valueDisplay.textContent = currentThreshold.toFixed(2);
|
||
sliderTrack.style.setProperty('--slider-value', (currentThreshold * 100) + '%');
|
||
filterAndDisplayStocks();
|
||
});
|
||
|
||
// 搜索框
|
||
const searchInput = document.getElementById('searchInput');
|
||
const searchBox = document.querySelector('.search-box');
|
||
const clearSearch = document.getElementById('clearSearch');
|
||
|
||
searchInput.addEventListener('input', function() {
|
||
searchQuery = this.value;
|
||
if (searchQuery) {
|
||
searchBox.classList.add('has-value');
|
||
} else {
|
||
searchBox.classList.remove('has-value');
|
||
}
|
||
filterAndDisplayStocks();
|
||
});
|
||
|
||
clearSearch.addEventListener('click', function() {
|
||
searchInput.value = '';
|
||
searchQuery = '';
|
||
searchBox.classList.remove('has-value');
|
||
filterAndDisplayStocks();
|
||
});
|
||
|
||
// 排序选择
|
||
const sortSelect = document.getElementById('sortSelect');
|
||
sortSelect.addEventListener('change', function() {
|
||
sortBy = this.value;
|
||
filterAndDisplayStocks();
|
||
});
|
||
|
||
// 排序顺序切换
|
||
const sortOrderBtn = document.getElementById('sortOrder');
|
||
sortOrderBtn.addEventListener('click', function() {
|
||
sortOrder = sortOrder === 'desc' ? 'asc' : 'desc';
|
||
this.textContent = sortOrder === 'desc' ? '↓' : '↑';
|
||
this.classList.toggle('active', sortOrder === 'desc');
|
||
filterAndDisplayStocks();
|
||
});
|
||
|
||
// 详细图切换
|
||
const detailToggle = document.getElementById('detailToggle');
|
||
detailToggle.addEventListener('click', function() {
|
||
showDetailCharts = !showDetailCharts;
|
||
this.textContent = showDetailCharts ? '详细图: 开' : '详细图: 关';
|
||
this.classList.toggle('active', showDetailCharts);
|
||
filterAndDisplayStocks();
|
||
});
|
||
|
||
// 方向筛选芯片
|
||
const directionFilter = document.getElementById('directionFilter');
|
||
directionFilter.addEventListener('click', function(e) {
|
||
if (e.target.classList.contains('filter-chip')) {
|
||
directionFilter.querySelectorAll('.filter-chip').forEach(chip => chip.classList.remove('active'));
|
||
e.target.classList.add('active');
|
||
filters.direction = e.target.dataset.value;
|
||
filterAndDisplayStocks();
|
||
}
|
||
});
|
||
|
||
// 放量筛选芯片
|
||
const volumeFilter = document.getElementById('volumeFilter');
|
||
volumeFilter.addEventListener('click', function(e) {
|
||
if (e.target.classList.contains('filter-chip')) {
|
||
volumeFilter.querySelectorAll('.filter-chip').forEach(chip => chip.classList.remove('active'));
|
||
e.target.classList.add('active');
|
||
filters.volume = e.target.dataset.value;
|
||
filterAndDisplayStocks();
|
||
}
|
||
});
|
||
|
||
// 重置按钮
|
||
document.getElementById('resetBtn').addEventListener('click', resetFilters);
|
||
|
||
// 模态框
|
||
document.getElementById('imageModal').addEventListener('click', function(e) {
|
||
if (e.target === this) closeModal();
|
||
});
|
||
}
|
||
|
||
function resetFilters() {
|
||
// 重置所有筛选和排序状态
|
||
currentThreshold = 0;
|
||
filters = { direction: 'all', volume: 'all' };
|
||
sortBy = 'strength';
|
||
sortOrder = 'desc';
|
||
searchQuery = '';
|
||
|
||
// 重置UI
|
||
document.getElementById('strengthSlider').value = 0;
|
||
document.getElementById('strengthValue').textContent = '0.00';
|
||
document.getElementById('sliderTrack').style.setProperty('--slider-value', '0%');
|
||
|
||
document.getElementById('searchInput').value = '';
|
||
document.querySelector('.search-box').classList.remove('has-value');
|
||
|
||
document.getElementById('sortSelect').value = 'strength';
|
||
const sortOrderBtn = document.getElementById('sortOrder');
|
||
sortOrderBtn.textContent = '↓';
|
||
sortOrderBtn.classList.add('active');
|
||
|
||
document.querySelectorAll('#directionFilter .filter-chip').forEach((chip, i) => {
|
||
chip.classList.toggle('active', i === 0);
|
||
});
|
||
filters.direction = 'all';
|
||
|
||
document.querySelectorAll('#volumeFilter .filter-chip').forEach((chip, i) => {
|
||
chip.classList.toggle('active', i === 0);
|
||
});
|
||
filters.volume = 'all';
|
||
|
||
filterAndDisplayStocks();
|
||
}
|
||
|
||
function filterAndDisplayStocks() {
|
||
let result = [...allStocks];
|
||
|
||
// 强度阈值筛选
|
||
result = result.filter(stock => stock.strength >= currentThreshold);
|
||
|
||
// 方向筛选
|
||
if (filters.direction !== 'all') {
|
||
result = result.filter(stock => stock.direction === filters.direction);
|
||
}
|
||
|
||
// 放量确认筛选
|
||
if (filters.volume !== 'all') {
|
||
const value = filters.volume === 'true';
|
||
result = result.filter(stock => stock.volumeConfirmed === (value ? 'True' : 'False'));
|
||
}
|
||
|
||
// 搜索
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase();
|
||
result = result.filter(stock =>
|
||
stock.code.toLowerCase().includes(q) ||
|
||
stock.name.toLowerCase().includes(q)
|
||
);
|
||
}
|
||
|
||
// 排序
|
||
result.sort((a, b) => {
|
||
let aVal, bVal;
|
||
if (sortBy === 'strength') {
|
||
aVal = a.strength;
|
||
bVal = b.strength;
|
||
} else if (sortBy === 'widthRatio') {
|
||
aVal = a.widthRatio;
|
||
bVal = b.widthRatio;
|
||
} else if (sortBy === 'touches') {
|
||
aVal = a.touchesUpper + a.touchesLower;
|
||
bVal = b.touchesUpper + b.touchesLower;
|
||
}
|
||
return sortOrder === 'desc' ? bVal - aVal : aVal - bVal;
|
||
});
|
||
|
||
renderGrid(result);
|
||
updateStats(result);
|
||
}
|
||
|
||
function renderGrid(stocks) {
|
||
const grid = document.getElementById('stocksGrid');
|
||
const emptyState = document.getElementById('emptyState');
|
||
|
||
if (stocks.length === 0) {
|
||
grid.style.display = 'none';
|
||
emptyState.style.display = 'block';
|
||
} else {
|
||
grid.style.display = 'grid';
|
||
emptyState.style.display = 'none';
|
||
grid.innerHTML = stocks.map(stock => createStockCard(stock)).join('');
|
||
}
|
||
}
|
||
|
||
function updateStats(filteredStocks) {
|
||
document.getElementById('totalStocks').textContent = allStocks.length;
|
||
document.getElementById('displayedStocks').textContent = filteredStocks.length;
|
||
const avgStrength = filteredStocks.length > 0
|
||
? filteredStocks.reduce((sum, s) => sum + s.strength, 0) / filteredStocks.length
|
||
: 0;
|
||
document.getElementById('avgStrength').textContent = avgStrength.toFixed(3);
|
||
}
|
||
|
||
function createStockCard(stock) {
|
||
const directionText = stock.direction === 'up' ? '↑ 向上' :
|
||
stock.direction === 'down' ? '↓ 向下' : '— 无';
|
||
const directionClass = stock.direction === 'up' ? 'direction-up' :
|
||
stock.direction === 'down' ? 'direction-down' : '';
|
||
const volumeText = stock.volumeConfirmed === 'True' ? '✓' :
|
||
stock.volumeConfirmed === 'False' ? '✗' : '—';
|
||
const chartPath = showDetailCharts ? stock.chartPathDetail : stock.chartPath;
|
||
const fallbackPath = showDetailCharts ? stock.chartPath : stock.chartPathDetail;
|
||
|
||
return `
|
||
<div class="stock-card" onclick="openModal('${stock.chartPath}', '${stock.chartPathDetail}')">
|
||
<div class="card-header">
|
||
<div class="stock-identity">
|
||
<div class="stock-name">${stock.name}</div>
|
||
<span class="stock-code">${stock.code}</span>
|
||
</div>
|
||
<div class="strength-badge">
|
||
<div class="strength-value">${stock.strength.toFixed(3)}</div>
|
||
<div class="strength-label">强度分</div>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="metrics-grid">
|
||
<div class="metric-item">
|
||
<span class="metric-label">突破方向</span>
|
||
<span class="metric-value ${directionClass}">${directionText}</span>
|
||
</div>
|
||
<div class="metric-item">
|
||
<span class="metric-label">宽度比</span>
|
||
<span class="metric-value">${stock.widthRatio.toFixed(2)}</span>
|
||
</div>
|
||
<div class="metric-item">
|
||
<span class="metric-label">触碰次数</span>
|
||
<span class="metric-value">${stock.touchesUpper}/${stock.touchesLower}</span>
|
||
</div>
|
||
<div class="metric-item">
|
||
<span class="metric-label">放量确认</span>
|
||
<span class="metric-value">${volumeText}</span>
|
||
</div>
|
||
<div class="metric-item">
|
||
<span class="metric-label">边界利用率</span>
|
||
<span class="metric-value">${(stock.boundaryUtilization || 0).toFixed(2)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="chart-container">
|
||
<img src="${chartPath}"
|
||
alt="${stock.name}"
|
||
class="stock-chart"
|
||
data-fallback-src="${fallbackPath}"
|
||
onerror="handleImageError(this)">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function handleImageError(img) {
|
||
const fallbackSrc = img.dataset.fallbackSrc;
|
||
if (fallbackSrc && img.src !== fallbackSrc && img.dataset.fallbackTried !== 'true') {
|
||
img.dataset.fallbackTried = 'true';
|
||
img.src = fallbackSrc;
|
||
return;
|
||
}
|
||
if (img.dataset.errorHandled) return;
|
||
img.dataset.errorHandled = 'true';
|
||
img.style.display = 'none';
|
||
const noChart = document.createElement('div');
|
||
noChart.className = 'no-chart';
|
||
noChart.textContent = '图表不可用';
|
||
img.parentElement.appendChild(noChart);
|
||
}
|
||
|
||
function openModal(defaultPath, detailPath) {
|
||
event.stopPropagation();
|
||
const modalImage = document.getElementById('modalImage');
|
||
const targetPath = showDetailCharts ? detailPath : defaultPath;
|
||
const fallbackPath = showDetailCharts ? defaultPath : detailPath;
|
||
modalImage.dataset.fallbackTried = 'false';
|
||
modalImage.dataset.fallbackSrc = fallbackPath;
|
||
modalImage.src = targetPath;
|
||
document.getElementById('imageModal').classList.add('show');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('imageModal').classList.remove('show');
|
||
}
|
||
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') closeModal();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>'''
|
||
|
||
# 替换数据
|
||
stocks_json = json.dumps(stocks, ensure_ascii=False, indent=2)
|
||
html = html_template.replace('STOCK_DATA', stocks_json)
|
||
html = html.replace('DATA_DATE', str(date))
|
||
html = html.replace('TOTAL_STOCKS', str(len(stocks)))
|
||
|
||
with open(output_path, 'w', encoding='utf-8') as f:
|
||
f.write(html)
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="生成包含内嵌数据的股票查看器HTML")
|
||
parser.add_argument(
|
||
"--date",
|
||
type=int,
|
||
default=None,
|
||
help="指定日期(YYYYMMDD),默认为最新日期"
|
||
)
|
||
parser.add_argument(
|
||
"--input",
|
||
default=os.path.join("outputs", "converging_triangles", "all_results.csv"),
|
||
help="输入CSV路径"
|
||
)
|
||
parser.add_argument(
|
||
"--output",
|
||
default=os.path.join("outputs", "converging_triangles", "stock_viewer.html"),
|
||
help="输出HTML路径"
|
||
)
|
||
parser.add_argument(
|
||
"--all-stocks",
|
||
action="store_true",
|
||
help="显示所有108只股票(包括不满足条件的)"
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
print("=" * 70)
|
||
print("生成股票查看器HTML")
|
||
print("=" * 70)
|
||
|
||
if not os.path.exists(args.input):
|
||
print(f"错误: CSV文件不存在: {args.input}")
|
||
print("请先运行: python scripts/pipeline_converging_triangle.py")
|
||
return
|
||
|
||
print(f"读取数据: {args.input}")
|
||
print(f"模式: {'所有股票' if args.all_stocks else '仅满足条件的股票'}")
|
||
|
||
data_dir = "data"
|
||
stocks, date = load_stock_data(args.input, args.date, args.all_stocks, data_dir)
|
||
|
||
print(f"数据日期: {date}")
|
||
print(f"股票数量: {len(stocks)}")
|
||
if args.all_stocks:
|
||
has_triangle = sum(1 for s in stocks if s.get('hasTriangle', False))
|
||
print(f" - 有三角形形态: {has_triangle}")
|
||
print(f" - 无三角形形态: {len(stocks) - has_triangle}")
|
||
|
||
print(f"生成HTML: {args.output}")
|
||
generate_html(stocks, date, args.output)
|
||
|
||
print("\n完成!")
|
||
print(f"\n用浏览器打开: {args.output}")
|
||
print("=" * 70)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|