technical-patterns-lab/scripts/generate_stock_viewer.py
褚宏光 bf6baa5483 Add scoring module and enhance HTML viewer with standardization
- Add scripts/scoring/ module with normalizer, sensitivity analysis, and config
- Enhance stock_viewer.html with standardized scoring display
- Add integration tests and normalization verification scripts
- Add documentation for standardization implementation and usage guides
- Add data distribution analysis reports for strength scoring dimensions
- Update discussion documents with algorithm optimization plans
2026-01-30 18:43:37 +08:00

2161 lines
86 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
import pandas as pd
# 添加scoring模块路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'scoring'))
try:
from scoring import (
normalize_all,
calculate_strength,
CONFIG_EQUAL,
CONFIG_AGGRESSIVE,
CONFIG_CONSERVATIVE,
CONFIG_VOLUME_FOCUS,
# 单维度测试模式
CONFIG_TEST_PRICE,
CONFIG_TEST_CONVERGENCE,
CONFIG_TEST_VOLUME,
CONFIG_TEST_GEOMETRY,
CONFIG_TEST_ACTIVITY,
CONFIG_TEST_TILT,
)
SCORING_AVAILABLE = True
except ImportError as e:
print(f"警告: 无法导入scoring模块: {e}")
print("将使用原始强度分,不进行标准化")
SCORING_AVAILABLE = False
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读取有强度分的股票
rows_list = []
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
rows_list.append(row)
except:
continue
# 转换为DataFrame以进行标准化
if rows_list and SCORING_AVAILABLE:
df = pd.DataFrame(rows_list)
# 转换数值列
numeric_cols = ['breakout_strength_up', 'breakout_strength_down',
'price_score_up', 'price_score_down', 'convergence_score',
'volume_score', 'geometry_score', 'activity_score', 'tilt_score']
for col in numeric_cols:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0)
# 执行标准化
df_norm = normalize_all(df)
# 计算4种预设模式的强度分 (分别计算up和down)
from dataclasses import replace
# 等权模式
config_equal_up = replace(CONFIG_EQUAL, direction='up')
config_equal_down = replace(CONFIG_EQUAL, direction='down')
df_norm['strength_equal_up'] = calculate_strength(df_norm, config_equal_up)
df_norm['strength_equal_down'] = calculate_strength(df_norm, config_equal_down)
# 激进模式
config_agg_up = replace(CONFIG_AGGRESSIVE, direction='up')
config_agg_down = replace(CONFIG_AGGRESSIVE, direction='down')
df_norm['strength_aggressive_up'] = calculate_strength(df_norm, config_agg_up)
df_norm['strength_aggressive_down'] = calculate_strength(df_norm, config_agg_down)
# 保守模式
config_cons_up = replace(CONFIG_CONSERVATIVE, direction='up')
config_cons_down = replace(CONFIG_CONSERVATIVE, direction='down')
df_norm['strength_conservative_up'] = calculate_strength(df_norm, config_cons_up)
df_norm['strength_conservative_down'] = calculate_strength(df_norm, config_cons_down)
# 放量模式
config_vol_up = replace(CONFIG_VOLUME_FOCUS, direction='up')
config_vol_down = replace(CONFIG_VOLUME_FOCUS, direction='down')
df_norm['strength_volume_up'] = calculate_strength(df_norm, config_vol_up)
df_norm['strength_volume_down'] = calculate_strength(df_norm, config_vol_down)
# 单维度测试模式50%主导)
# 突破主导
config_test_price_up = replace(CONFIG_TEST_PRICE, direction='up')
config_test_price_down = replace(CONFIG_TEST_PRICE, direction='down')
df_norm['strength_test_price_up'] = calculate_strength(df_norm, config_test_price_up)
df_norm['strength_test_price_down'] = calculate_strength(df_norm, config_test_price_down)
# 收敛主导
config_test_conv_up = replace(CONFIG_TEST_CONVERGENCE, direction='up')
config_test_conv_down = replace(CONFIG_TEST_CONVERGENCE, direction='down')
df_norm['strength_test_convergence_up'] = calculate_strength(df_norm, config_test_conv_up)
df_norm['strength_test_convergence_down'] = calculate_strength(df_norm, config_test_conv_down)
# 成交量主导
config_test_vol_up = replace(CONFIG_TEST_VOLUME, direction='up')
config_test_vol_down = replace(CONFIG_TEST_VOLUME, direction='down')
df_norm['strength_test_volume_up'] = calculate_strength(df_norm, config_test_vol_up)
df_norm['strength_test_volume_down'] = calculate_strength(df_norm, config_test_vol_down)
# 形态主导
config_test_geo_up = replace(CONFIG_TEST_GEOMETRY, direction='up')
config_test_geo_down = replace(CONFIG_TEST_GEOMETRY, direction='down')
df_norm['strength_test_geometry_up'] = calculate_strength(df_norm, config_test_geo_up)
df_norm['strength_test_geometry_down'] = calculate_strength(df_norm, config_test_geo_down)
# 活跃主导
config_test_act_up = replace(CONFIG_TEST_ACTIVITY, direction='up')
config_test_act_down = replace(CONFIG_TEST_ACTIVITY, direction='down')
df_norm['strength_test_activity_up'] = calculate_strength(df_norm, config_test_act_up)
df_norm['strength_test_activity_down'] = calculate_strength(df_norm, config_test_act_down)
# 倾斜主导
config_test_tilt_up = replace(CONFIG_TEST_TILT, direction='up')
config_test_tilt_down = replace(CONFIG_TEST_TILT, direction='down')
df_norm['strength_test_tilt_up'] = calculate_strength(df_norm, config_test_tilt_up)
df_norm['strength_test_tilt_down'] = calculate_strength(df_norm, config_test_tilt_down)
else:
df_norm = None
# 构建stocks_map
for idx, row in enumerate(rows_list):
try:
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', ''),
'activityScore': float(row.get('activity_score', '0')),
'tiltScore': float(row.get('tilt_score', '0')),
'date': use_date,
'hasTriangle': True
}
# 添加标准化字段
if df_norm is not None:
norm_row = df_norm.iloc[idx]
stock['priceUpNorm'] = float(norm_row.get('price_score_up_norm', 0))
stock['priceDownNorm'] = float(norm_row.get('price_score_down_norm', 0))
stock['convergenceNorm'] = float(norm_row.get('convergence_score_norm', 0))
stock['volumeNorm'] = float(norm_row.get('volume_score_norm', 0))
stock['geometryNorm'] = float(norm_row.get('geometry_score_norm', 0))
stock['activityNorm'] = float(norm_row.get('activity_score_norm', 0))
stock['tiltNorm'] = float(norm_row.get('tilt_score_norm', 0))
# 添加预设模式强度分
stock['strengthEqualUp'] = float(norm_row.get('strength_equal_up', 0))
stock['strengthEqualDown'] = float(norm_row.get('strength_equal_down', 0))
stock['strengthAggressiveUp'] = float(norm_row.get('strength_aggressive_up', 0))
stock['strengthAggressiveDown'] = float(norm_row.get('strength_aggressive_down', 0))
stock['strengthConservativeUp'] = float(norm_row.get('strength_conservative_up', 0))
stock['strengthConservativeDown'] = float(norm_row.get('strength_conservative_down', 0))
stock['strengthVolumeUp'] = float(norm_row.get('strength_volume_up', 0))
stock['strengthVolumeDown'] = float(norm_row.get('strength_volume_down', 0))
# 添加单维度测试模式强度分
stock['strengthTestPriceUp'] = float(norm_row.get('strength_test_price_up', 0))
stock['strengthTestPriceDown'] = float(norm_row.get('strength_test_price_down', 0))
stock['strengthTestConvergenceUp'] = float(norm_row.get('strength_test_convergence_up', 0))
stock['strengthTestConvergenceDown'] = float(norm_row.get('strength_test_convergence_down', 0))
stock['strengthTestVolumeUp'] = float(norm_row.get('strength_test_volume_up', 0))
stock['strengthTestVolumeDown'] = float(norm_row.get('strength_test_volume_down', 0))
stock['strengthTestGeometryUp'] = float(norm_row.get('strength_test_geometry_up', 0))
stock['strengthTestGeometryDown'] = float(norm_row.get('strength_test_geometry_down', 0))
stock['strengthTestActivityUp'] = float(norm_row.get('strength_test_activity_up', 0))
stock['strengthTestActivityDown'] = float(norm_row.get('strength_test_activity_down', 0))
stock['strengthTestTiltUp'] = float(norm_row.get('strength_test_tilt_up', 0))
stock['strengthTestTiltDown'] = float(norm_row.get('strength_test_tilt_down', 0))
# 根据方向选择强度分
if stock['direction'] == 'up':
stock['strengthEqual'] = stock['strengthEqualUp']
stock['strengthAggressive'] = stock['strengthAggressiveUp']
stock['strengthConservative'] = stock['strengthConservativeUp']
stock['strengthVolume'] = stock['strengthVolumeUp']
stock['strengthTestPrice'] = stock['strengthTestPriceUp']
stock['strengthTestConvergence'] = stock['strengthTestConvergenceUp']
stock['strengthTestVolume'] = stock['strengthTestVolumeUp']
stock['strengthTestGeometry'] = stock['strengthTestGeometryUp']
stock['strengthTestActivity'] = stock['strengthTestActivityUp']
stock['strengthTestTilt'] = stock['strengthTestTiltUp']
elif stock['direction'] == 'down':
stock['strengthEqual'] = stock['strengthEqualDown']
stock['strengthAggressive'] = stock['strengthAggressiveDown']
stock['strengthConservative'] = stock['strengthConservativeDown']
stock['strengthVolume'] = stock['strengthVolumeDown']
stock['strengthTestPrice'] = stock['strengthTestPriceDown']
stock['strengthTestConvergence'] = stock['strengthTestConvergenceDown']
stock['strengthTestVolume'] = stock['strengthTestVolumeDown']
stock['strengthTestGeometry'] = stock['strengthTestGeometryDown']
stock['strengthTestActivity'] = stock['strengthTestActivityDown']
stock['strengthTestTilt'] = stock['strengthTestTiltDown']
else:
# 无方向时取两者最大值
stock['strengthEqual'] = max(stock['strengthEqualUp'], stock['strengthEqualDown'])
stock['strengthAggressive'] = max(stock['strengthAggressiveUp'], stock['strengthAggressiveDown'])
stock['strengthConservative'] = max(stock['strengthConservativeUp'], stock['strengthConservativeDown'])
stock['strengthVolume'] = max(stock['strengthVolumeUp'], stock['strengthVolumeDown'])
stock['strengthTestPrice'] = max(stock['strengthTestPriceUp'], stock['strengthTestPriceDown'])
stock['strengthTestConvergence'] = max(stock['strengthTestConvergenceUp'], stock['strengthTestConvergenceDown'])
stock['strengthTestVolume'] = max(stock['strengthTestVolumeUp'], stock['strengthTestVolumeDown'])
stock['strengthTestGeometry'] = max(stock['strengthTestGeometryUp'], stock['strengthTestGeometryDown'])
stock['strengthTestActivity'] = max(stock['strengthTestActivityUp'], stock['strengthTestActivityDown'])
stock['strengthTestTilt'] = max(stock['strengthTestTiltUp'], stock['strengthTestTiltDown'])
else:
# 如果标准化不可用,设置默认值
stock['priceUpNorm'] = 0
stock['priceDownNorm'] = 0
stock['convergenceNorm'] = 0
stock['volumeNorm'] = 0
stock['geometryNorm'] = 0
stock['activityNorm'] = 0
stock['tiltNorm'] = 0
stock['strengthEqual'] = stock['strengthUp'] if stock['direction'] == 'up' else stock['strengthDown']
stock['strengthAggressive'] = stock['strengthEqual']
stock['strengthConservative'] = stock['strengthEqual']
stock['strengthVolume'] = stock['strengthEqual']
stock['strengthTestPrice'] = stock['strengthEqual']
stock['strengthTestConvergence'] = stock['strengthEqual']
stock['strengthTestVolume'] = stock['strengthEqual']
stock['strengthTestGeometry'] = stock['strengthEqual']
stock['strengthTestActivity'] = stock['strengthEqual']
stock['strengthTestTilt'] = stock['strengthEqual']
stock['strength'] = max(stock['strengthUp'], stock['strengthDown'])
# 清理文件名中的非法字符
clean_name = stock['name'].replace('*', '').replace('?', '').replace('"', '').replace('<', '').replace('>', '').replace('|', '').replace(':', '').replace('/', '').replace('\\', '')
stock['chartPath'] = f"charts/{use_date}_{stock_code}_{clean_name}.png"
stock['chartPathDetail'] = f"charts/{use_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:
print(f"处理股票 {row.get('stock_code', 'unknown')} 时出错: {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': '',
'activityScore': 0.0,
'tiltScore': 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,
# 标准化字段
'priceUpNorm': 0.0,
'priceDownNorm': 0.0,
'convergenceNorm': 0.0,
'volumeNorm': 0.0,
'geometryNorm': 0.0,
'activityNorm': 0.0,
'tiltNorm': 0.0,
'strengthEqual': 0.0,
'strengthAggressive': 0.0,
'strengthConservative': 0.0,
'strengthVolume': 0.0,
# 单维度测试模式
'strengthTestPrice': 0.0,
'strengthTestConvergence': 0.0,
'strengthTestVolume': 0.0,
'strengthTestGeometry': 0.0,
'strengthTestActivity': 0.0,
'strengthTestTilt': 0.0,
}
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">
<!-- Preset Modes Row -->
<div class="filter-section" style="margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border-subtle);">
<label class="filter-label">预设模式</label>
<div class="filter-chips" id="presetModes">
<div class="filter-chip active" data-mode="equal" title="各维度等权1/6">等权模式</div>
<div class="filter-chip" data-mode="aggressive" title="重视突破35%+成交量25%">激进模式</div>
<div class="filter-chip" data-mode="conservative" title="重视收敛30%+活跃25%">保守模式</div>
<div class="filter-chip" data-mode="volume" title="重视成交量35%">放量模式</div>
</div>
<label class="filter-label" style="margin-top: 12px;">单维度测试50%主导)</label>
<div class="filter-chips" id="presetModes2">
<div class="filter-chip" data-mode="test_price" title="突破50%+其余各10%">突破主导</div>
<div class="filter-chip" data-mode="test_convergence" title="收敛50%+其余各10%">收敛主导</div>
<div class="filter-chip" data-mode="test_volume" title="成交量50%+其余各10%">成交量主导</div>
<div class="filter-chip" data-mode="test_geometry" title="形态50%+其余各10%">形态主导</div>
<div class="filter-chip" data-mode="test_activity" title="活跃50%+其余各10%">活跃主导</div>
<div class="filter-chip" data-mode="test_tilt" title="倾斜50%+其余各10%">倾斜主导</div>
</div>
</div>
<!-- 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="current_mode">按当前模式强度分</option>
<option value="strength">按原始强度分</option>
<option value="widthRatio">按宽度比</option>
<option value="touches">按触碰次数</option>
<option value="convergenceNorm">按收敛度</option>
<option value="volumeNorm">按成交量</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>
<!-- Advanced Dimension Filters (Collapsible) -->
<div class="filter-section" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border-subtle);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; cursor: pointer;" onclick="toggleAdvancedFilters()">
<label class="filter-label" style="cursor: pointer;">高级维度筛选</label>
<span id="advancedToggleIcon" style="font-size: 18px; color: var(--text-secondary); transition: transform 0.3s;">▼</span>
</div>
<div id="advancedFilters" style="display: none;">
<div style="margin-bottom: 16px;">
<label style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: block;">突破幅度 ≥ <span id="priceThresholdValue" style="color: var(--accent-primary);">0.00</span></label>
<input type="range" id="priceThreshold" min="0" max="1" step="0.05" value="0" style="width: 100%;">
</div>
<div style="margin-bottom: 16px;">
<label style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: block;">收敛度 ≥ <span id="convergenceThresholdValue" style="color: var(--accent-primary);">0.00</span></label>
<input type="range" id="convergenceThreshold" min="0" max="1" step="0.05" value="0" style="width: 100%;">
</div>
<div style="margin-bottom: 16px;">
<label style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: block;">成交量 ≥ <span id="volumeThresholdValue" style="color: var(--accent-primary);">0.00</span></label>
<input type="range" id="volumeThreshold" min="0" max="1" step="0.05" value="0" style="width: 100%;">
</div>
<div style="margin-bottom: 16px;">
<label style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: block;">形态规则度 ≥ <span id="geometryThresholdValue" style="color: var(--accent-primary);">0.00</span></label>
<input type="range" id="geometryThreshold" min="0" max="1" step="0.05" value="0" style="width: 100%;">
</div>
<div style="margin-bottom: 16px;">
<label style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: block;">活跃度 ≥ <span id="activityThresholdValue" style="color: var(--accent-primary);">0.00</span></label>
<input type="range" id="activityThreshold" min="0" max="1" step="0.05" value="0" style="width: 100%;">
</div>
<div style="margin-bottom: 16px;">
<label style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: block;">倾斜度 ≥ <span id="tiltThresholdValue" style="color: var(--accent-primary);">0.00</span></label>
<input type="range" id="tiltThreshold" min="0" max="1" step="0.05" value="0" style="width: 100%;">
</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'
priceNorm: 0, // 突破幅度阈值
convergenceNorm: 0, // 收敛度阈值
volumeNorm: 0, // 成交量阈值
geometryNorm: 0, // 形态规则度阈值
activityNorm: 0, // 活跃度阈值
tiltNorm: 0 // 倾斜度阈值
};
let currentPresetMode = 'equal'; // 'equal', 'aggressive', 'conservative', 'volume'
let sortBy = 'current_mode'; // 'current_mode', 'strength', 'widthRatio', 'touches', etc.
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 presetModes = document.getElementById('presetModes');
const presetModes2 = document.getElementById('presetModes2');
function handlePresetModeClick(e, currentGroup, otherGroup) {
if (e.target.classList.contains('filter-chip')) {
// 取消当前组所有选中
currentGroup.querySelectorAll('.filter-chip').forEach(chip => chip.classList.remove('active'));
// 取消另一组所有选中
otherGroup.querySelectorAll('.filter-chip').forEach(chip => chip.classList.remove('active'));
// 选中当前点击的
e.target.classList.add('active');
currentPresetMode = e.target.dataset.mode;
// 切换模式时刷新显示排序会根据current_mode自动使用新模式
filterAndDisplayStocks();
}
}
presetModes.addEventListener('click', function(e) {
handlePresetModeClick(e, presetModes, presetModes2);
});
presetModes2.addEventListener('click', function(e) {
handlePresetModeClick(e, presetModes2, presetModes);
});
// 方向筛选芯片
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);
// 高级筛选滑块
const advancedSliders = [
{ id: 'priceThreshold', valueId: 'priceThresholdValue', key: 'priceNorm' },
{ id: 'convergenceThreshold', valueId: 'convergenceThresholdValue', key: 'convergenceNorm' },
{ id: 'volumeThreshold', valueId: 'volumeThresholdValue', key: 'volumeNorm' },
{ id: 'geometryThreshold', valueId: 'geometryThresholdValue', key: 'geometryNorm' },
{ id: 'activityThreshold', valueId: 'activityThresholdValue', key: 'activityNorm' },
{ id: 'tiltThreshold', valueId: 'tiltThresholdValue', key: 'tiltNorm' }
];
advancedSliders.forEach(slider => {
const element = document.getElementById(slider.id);
const valueDisplay = document.getElementById(slider.valueId);
if (element && valueDisplay) {
element.addEventListener('input', function() {
const value = parseFloat(this.value);
filters[slider.key] = value;
valueDisplay.textContent = value.toFixed(2);
filterAndDisplayStocks();
});
}
});
// 模态框
document.getElementById('imageModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
}
function resetFilters() {
// 重置所有筛选和排序状态
currentThreshold = 0;
filters = {
direction: 'all',
volume: 'all',
priceNorm: 0,
convergenceNorm: 0,
volumeNorm: 0,
geometryNorm: 0,
activityNorm: 0,
tiltNorm: 0
};
sortBy = 'current_mode';
sortOrder = 'desc';
searchQuery = '';
currentPresetMode = 'equal'; // 重置预设模式为等权
// 重置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 = 'current_mode';
// 重置预设模式按钮
document.querySelectorAll('#presetModes .filter-chip').forEach((chip, i) => {
chip.classList.toggle('active', i === 0); // 选中第一个(等权模式)
});
document.querySelectorAll('#presetModes2 .filter-chip').forEach(chip => {
chip.classList.remove('active');
});
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';
// 重置高级筛选滑块
const advancedSliders = ['priceThreshold', 'convergenceThreshold', 'volumeThreshold',
'geometryThreshold', 'activityThreshold', 'tiltThreshold'];
const valueIds = ['priceThresholdValue', 'convergenceThresholdValue', 'volumeThresholdValue',
'geometryThresholdValue', 'activityThresholdValue', 'tiltThresholdValue'];
advancedSliders.forEach((id, i) => {
const slider = document.getElementById(id);
const valueDisplay = document.getElementById(valueIds[i]);
if (slider && valueDisplay) {
slider.value = 0;
valueDisplay.textContent = '0.00';
}
});
filterAndDisplayStocks();
}
function toggleAdvancedFilters() {
const panel = document.getElementById('advancedFilters');
const icon = document.getElementById('advancedToggleIcon');
if (panel.style.display === 'none') {
panel.style.display = 'block';
icon.style.transform = 'rotate(180deg)';
} else {
panel.style.display = 'none';
icon.style.transform = 'rotate(0deg)';
}
}
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 (filters.priceNorm > 0) {
result = result.filter(stock => (stock.priceUpNorm || 0) >= filters.priceNorm);
}
if (filters.convergenceNorm > 0) {
result = result.filter(stock => (stock.convergenceNorm || 0) >= filters.convergenceNorm);
}
if (filters.volumeNorm > 0) {
result = result.filter(stock => (stock.volumeNorm || 0) >= filters.volumeNorm);
}
if (filters.geometryNorm > 0) {
result = result.filter(stock => (stock.geometryNorm || 0) >= filters.geometryNorm);
}
if (filters.activityNorm > 0) {
result = result.filter(stock => (stock.activityNorm || 0) >= filters.activityNorm);
}
if (filters.tiltNorm > 0) {
result = result.filter(stock => (stock.tiltNorm || 0) >= filters.tiltNorm);
}
// 搜索
if (searchQuery) {
const q = searchQuery.toLowerCase();
result = result.filter(stock =>
stock.code.toLowerCase().includes(q) ||
stock.name.toLowerCase().includes(q)
);
}
// 排序 - 根据当前模式动态获取排序字段
const modeToKeyMap = {
'equal': 'strengthEqual',
'aggressive': 'strengthAggressive',
'conservative': 'strengthConservative',
'volume': 'strengthVolume',
'test_price': 'strengthTestPrice',
'test_convergence': 'strengthTestConvergence',
'test_volume': 'strengthTestVolume',
'test_geometry': 'strengthTestGeometry',
'test_activity': 'strengthTestActivity',
'test_tilt': 'strengthTestTilt'
};
result.sort((a, b) => {
let aVal, bVal;
if (sortBy === 'current_mode') {
// 根据当前预设模式获取对应的强度分字段
const key = modeToKeyMap[currentPresetMode] || 'strengthEqual';
aVal = a[key] || 0;
bVal = b[key] || 0;
} else 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;
} else if (sortBy === 'convergenceNorm') {
aVal = a.convergenceNorm || 0;
bVal = b.convergenceNorm || 0;
} else if (sortBy === 'volumeNorm') {
aVal = a.volumeNorm || 0;
bVal = b.volumeNorm || 0;
} else {
aVal = a.strength;
bVal = b.strength;
}
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('');
// 绘制所有雷达图
setTimeout(() => {
document.querySelectorAll('.radar-canvas').forEach(canvas => {
const valuesStr = canvas.dataset.values;
if (valuesStr) {
const values = valuesStr.split(',').map(v => parseFloat(v) || 0);
drawMiniRadar(canvas, values);
}
});
}, 0);
}
}
function updateStats(filteredStocks) {
document.getElementById('totalStocks').textContent = allStocks.length;
document.getElementById('displayedStocks').textContent = filteredStocks.length;
// 根据当前模式计算平均强度
const modeKeyMap = {
'equal': 'strengthEqual',
'aggressive': 'strengthAggressive',
'conservative': 'strengthConservative',
'volume': 'strengthVolume',
'test_price': 'strengthTestPrice',
'test_convergence': 'strengthTestConvergence',
'test_volume': 'strengthTestVolume',
'test_geometry': 'strengthTestGeometry',
'test_activity': 'strengthTestActivity',
'test_tilt': 'strengthTestTilt'
};
let avgStrength = 0;
if (filteredStocks.length > 0) {
const key = modeKeyMap[currentPresetMode];
if (key) {
avgStrength = filteredStocks.reduce((sum, s) => sum + (s[key] || 0), 0) / filteredStocks.length;
} else {
avgStrength = filteredStocks.reduce((sum, s) => sum + s.strength, 0) / filteredStocks.length;
}
}
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;
// 根据当前模式选择强度分
let displayStrength = stock.strength;
let modeName = '原始';
const modeMap = {
'equal': { key: 'strengthEqual', name: '等权' },
'aggressive': { key: 'strengthAggressive', name: '激进' },
'conservative': { key: 'strengthConservative', name: '保守' },
'volume': { key: 'strengthVolume', name: '放量' },
'test_price': { key: 'strengthTestPrice', name: '突破主导' },
'test_convergence': { key: 'strengthTestConvergence', name: '收敛主导' },
'test_volume': { key: 'strengthTestVolume', name: '成交量主导' },
'test_geometry': { key: 'strengthTestGeometry', name: '形态主导' },
'test_activity': { key: 'strengthTestActivity', name: '活跃主导' },
'test_tilt': { key: 'strengthTestTilt', name: '倾斜主导' }
};
if (modeMap[currentPresetMode]) {
displayStrength = stock[modeMap[currentPresetMode].key] || stock.strength;
modeName = modeMap[currentPresetMode].name;
}
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">${displayStrength.toFixed(3)}</div>
<div class="strength-label">${modeName}强度分</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.activityScore || 0).toFixed(2)}</span>
</div>
<div class="metric-item">
<span class="metric-label">倾斜度</span>
<span class="metric-value">${(stock.tiltScore || 0).toFixed(2)}</span>
</div>
</div>
<!-- 6维度标准化得分展示 -->
<div class="dimensions-panel" style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<div style="font-size: 12px; color: var(--text-secondary); font-weight: 600;">标准化维度</div>
<canvas class="radar-canvas"
data-values="${stock.priceUpNorm || 0},${stock.convergenceNorm || 0},${stock.volumeNorm || 0},${stock.geometryNorm || 0},${stock.activityNorm || 0},${stock.tiltNorm || 0}"
width="80" height="80"
style="cursor: pointer;"
onclick="event.stopPropagation();"></canvas>
</div>
<div>
${createDimensionBar('突破幅度', stock.priceUpNorm || 0, stock.direction === 'up')}
${createDimensionBar('收敛度', stock.convergenceNorm || 0)}
${createDimensionBar('成交量', stock.volumeNorm || 0)}
${createDimensionBar('形态规则', stock.geometryNorm || 0)}
${createDimensionBar('活跃度', stock.activityNorm || 0)}
${createDimensionBar('倾斜度', stock.tiltNorm || 0)}
</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 createDimensionBar(label, value, highlight = false) {
const percentage = (value * 100).toFixed(0);
const color = highlight ? 'var(--accent-primary)' : 'var(--accent-secondary)';
return `
<div style="margin-bottom: 8px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="font-size: 11px; color: var(--text-secondary);">${label}</span>
<span style="font-size: 11px; font-family: 'JetBrains Mono', monospace; color: ${color};">${value.toFixed(2)}</span>
</div>
<div style="height: 4px; background: var(--bg-card); border-radius: 2px; overflow: hidden;">
<div style="height: 100%; width: ${percentage}%; background: ${color}; transition: width 0.3s;"></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');
}
// 绘制迷你雷达图
function drawMiniRadar(canvas, values) {
const ctx = canvas.getContext('2d');
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const radius = Math.min(centerX, centerY) - 10;
const angleStep = (Math.PI * 2) / 6;
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制背景网格
ctx.strokeStyle = 'rgba(139, 146, 168, 0.2)';
ctx.lineWidth = 1;
for (let i = 1; i <= 3; i++) {
ctx.beginPath();
const r = radius * (i / 3);
for (let j = 0; j <= 6; j++) {
const angle = j * angleStep - Math.PI / 2;
const x = centerX + r * Math.cos(angle);
const y = centerY + r * Math.sin(angle);
if (j === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
// 绘制轴线
ctx.strokeStyle = 'rgba(139, 146, 168, 0.3)';
for (let i = 0; i < 6; i++) {
const angle = i * angleStep - Math.PI / 2;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.lineTo(
centerX + radius * Math.cos(angle),
centerY + radius * Math.sin(angle)
);
ctx.stroke();
}
// 绘制数据多边形
ctx.fillStyle = 'rgba(0, 212, 170, 0.2)';
ctx.strokeStyle = 'rgba(0, 212, 170, 0.8)';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i <= 6; i++) {
const angle = i * angleStep - Math.PI / 2;
const value = values[i % 6] || 0;
const r = radius * value;
const x = centerX + r * Math.cos(angle);
const y = centerY + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
// 绘制数据点
ctx.fillStyle = '#00d4aa';
for (let i = 0; i < 6; i++) {
const angle = i * angleStep - Math.PI / 2;
const value = values[i] || 0;
const r = radius * value;
const x = centerX + r * Math.cos(angle);
const y = centerY + r * Math.sin(angle);
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
}
}
// 切换雷达图/进度条视图(可选功能,暂不实现)
function toggleRadarView(code) {
// 可以实现点击雷达图放大查看
console.log('Toggle radar view for:', code);
}
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()