- 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
2161 lines
86 KiB
Python
2161 lines
86 KiB
Python
"""
|
||
生成包含内嵌数据的股票查看器HTML
|
||
|
||
用法:
|
||
python scripts/generate_stock_viewer.py
|
||
python scripts/generate_stock_viewer.py --date 20260120
|
||
python scripts/generate_stock_viewer.py --all-stocks # 显示所有108只股票
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import os
|
||
import json
|
||
import sys
|
||
import pickle
|
||
import numpy as np
|
||
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()">×</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()
|