technical-patterns-lab/scripts/generate_stock_viewer.py
褚宏光 24652b5790 Enhance converging triangle analysis with boundary utilization scoring and detailed chart mode
- 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.
2026-01-27 18:54:56 +08:00

1560 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
生成包含内嵌数据的股票查看器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()">&times;</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()