Compare commits
182 Commits
feature_20
...
72836fa5d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 72836fa5d4 | |||
| 17479a7362 | |||
| f5c46ae71b | |||
|
|
fff937a7d5 | ||
| 710dc07582 | |||
|
|
1ecd3e6d10 | ||
|
|
59fdb150a9 | ||
|
|
293886b54a | ||
|
|
f304268af9 | ||
|
|
cbef50c3e5 | ||
|
|
9990d95e28 | ||
| afe1180736 | |||
| d28e25b37c | |||
| 64249fc768 | |||
|
|
4a5e18a90d | ||
|
|
b7315bbdb4 | ||
|
|
378df947a9 | ||
|
|
a9c21d8478 | ||
|
|
7be35d7bb8 | ||
|
|
4e5f999881 | ||
|
|
c1b8a98bb4 | ||
|
|
46be0249a8 | ||
|
|
f2713e5e0a | ||
|
|
e48bcbb74b | ||
|
|
d37cc720ef | ||
|
|
0775409c9f | ||
|
|
a89489ba46 | ||
|
|
e493ae5ad1 | ||
|
|
83b5941281 | ||
| ff8a7b2dfb | |||
| b4de2ca5fa | |||
| aaca6b47ed | |||
| 4922baa8ad | |||
| 2770a82172 | |||
| 9603adbd31 | |||
|
|
6683e7fce7 | ||
| 71e0826244 | |||
|
|
9ba180a3ee | ||
|
|
5d83532b61 | ||
|
|
464cca5ace | ||
|
|
fcdf135bd8 | ||
|
|
2449619f43 | ||
| db351ae494 | |||
| f1603977f4 | |||
| e3b98eaa6a | |||
| 073a0cbd7e | |||
| 72e72833ab | |||
| 77f1643a58 | |||
| 8971cebaa3 | |||
| 3967a06f1c | |||
| 424c5ecb3e | |||
| 024a34cdd0 | |||
| b6b9a6b5dd | |||
| 4391c112c6 | |||
| 6910866b05 | |||
| a2b734368b | |||
| da81c4f8aa | |||
| d87ae07a06 | |||
| b2ef7963fd | |||
| 426ec44027 | |||
| 627822ed24 | |||
| dd963f297c | |||
| 1471bf806a | |||
| 7ebe365b0a | |||
| d76b23d8ff | |||
| 35100438e0 | |||
| ce3c30be2f | |||
| 9f2e0d8276 | |||
| d408dccc7b | |||
| 3b2ecc59f5 | |||
| 4380976787 | |||
| 648d672a35 | |||
| ba0656fad3 | |||
| 4ccbb09067 | |||
| 0060911e41 | |||
| dc03fad2a5 | |||
| d44f8d8fa8 | |||
| 5288666446 | |||
| 84b32c21a3 | |||
| c72c512100 | |||
| 4ccd43f025 | |||
| ed9d49da01 | |||
| 108204653a | |||
| 05d26b373a | |||
| 235cbf48a8 | |||
| 646bc25b4f | |||
| 5e8c2400a3 | |||
| 1949d9b922 | |||
|
|
cc33dd29eb | ||
|
|
f990b0a142 | ||
| 32f398df7a | |||
|
|
f38b8b7f16 | ||
|
|
9f99ea7aee | ||
| bbe4cca2d9 | |||
| 969780e784 | |||
| 6ecae5ed76 | |||
| 966ee31f35 | |||
| 445a5226d5 | |||
| c4e95e9c1e | |||
| e24e0604b8 | |||
| 28de373b85 | |||
| 39c6eacb58 | |||
| 2a4e2a41ec | |||
| 435692ce0f | |||
| d7193c3a63 | |||
| a6c78c0fa5 | |||
| 4b3ee81341 | |||
| e58f4e4ecf | |||
| 41be30e4d5 | |||
| f96a333cae | |||
| 34bc635072 | |||
| 969b7d3b82 | |||
| 7bc96e33b8 | |||
| 002c3beeac | |||
| 036aef1171 | |||
| 9117f373d4 | |||
| 3590226213 | |||
| 93bfecdafc | |||
| fb0f449017 | |||
| 89e51d1d4c | |||
| cdd96a69c5 | |||
| c689157ce6 | |||
| 8d6fd4cae7 | |||
| ac60e2d147 | |||
| 777f6f7723 | |||
| eb961d83f1 | |||
| 02ca4f48e6 | |||
| 985f49ea84 | |||
| de56e8512d | |||
| d6d2b0ca94 | |||
| 9d095be968 | |||
| 870b266a31 | |||
| bdad36bb16 | |||
| 198f456655 | |||
| 54c4f64a49 | |||
| 56e980f19d | |||
| d19d18810d | |||
| 63b4623522 | |||
| 3f87a3d1af | |||
| 0599e2dad3 | |||
| bf4521af47 | |||
| b68a62acfb | |||
| 34741155d3 | |||
| 736886fd40 | |||
| d6e567ba8a | |||
| 9829015cb3 | |||
| b7790db357 | |||
|
|
61c3f5057f | ||
|
|
d46738da1b | ||
|
|
fa14346ca2 | ||
| 429c2a4531 | |||
| 3cc7f2ca6e | |||
| 5f23844160 | |||
| 39ad523dad | |||
| 7d1c89a6a4 | |||
| ff42b17119 | |||
|
|
35823fd61f | ||
| 86e31fd2bf | |||
| dae1a539ac | |||
|
|
6c26f6dabc | ||
| 29cf0d7013 | |||
| d0c9d9b1fb | |||
| 2ebc1cbc97 | |||
| 68c7b6232d | |||
| 14514458ed | |||
| f23b859f77 | |||
| 8748e81a7b | |||
| 2c5b3b7b50 | |||
| 8c6ebe01ed | |||
|
|
fd0c614d90 | ||
| f545c9ec15 | |||
| b4791cbd4d | |||
| 85c29483dd | |||
| beb349ac2f | |||
| 7f5085ba8e | |||
| cca6f3a054 | |||
|
|
0bf2b01ca6 | ||
|
|
b151400c65 | ||
| 14c61b4e88 | |||
| 19284f3677 | |||
| b838777a42 | |||
| 3adefc6225 |
@@ -14,7 +14,8 @@ REACT_APP_ENABLE_MOCK=false
|
||||
REACT_APP_ENABLE_DEBUG=false
|
||||
|
||||
# 后端 API 地址(生产环境)
|
||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||
# 使用单独的 API 域名,静态资源走 CDN,API 走专用域名
|
||||
REACT_APP_API_URL=https://api.valuefrontier.cn
|
||||
|
||||
# PostHog 分析配置(生产环境)
|
||||
# PostHog API Key(从 PostHog 项目设置中获取)
|
||||
|
||||
10
.gitignore
vendored
@@ -22,6 +22,10 @@ node_modules/
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# 部署配置(包含密钥,不提交)
|
||||
.env.cos
|
||||
.env.deploy
|
||||
|
||||
# 日志
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -53,3 +57,9 @@ Thumbs.db
|
||||
docs/
|
||||
|
||||
src/assets/img/original-backup/
|
||||
|
||||
# 涨停分析静态数据(由 export_zt_data.py 生成,不提交到 Git)
|
||||
public/data/zt/
|
||||
|
||||
# 概念涨跌幅静态数据(由 export_concept_data.py 生成,不提交到 Git)
|
||||
public/data/concept/
|
||||
|
||||
1
MP_verify_17Fo4JhapMw6vtNa.txt
Normal file
@@ -0,0 +1 @@
|
||||
17Fo4JhapMw6vtNa
|
||||
BIN
__pycache__/alipay_config.cpython-310.pyc
Normal file
BIN
__pycache__/alipay_pay.cpython-310.pyc
Normal file
BIN
__pycache__/wechat_pay_worker.cpython-310.pyc
Normal file
1
alipay/应用公钥.txt
Normal file
@@ -0,0 +1 @@
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkBfIjOiu8vfmOSq1BXcjDsAqQ+xtwGO0aCn0VrhVzc0T70nDchaW/TJ4nW8qlRMBlgfTi00jDGFiW4ND9JHc4aES8yiDSNeaBW4gLQhC1isvpOndyu4YgDc+2lMfghv9+6D8uFl9VS8Vk6o7D+5SiE7F8aBn49rrLyvsmdN5i6eLxIuY9NM58/o0xVG5f3ktGqfFKzhtclPbt8ej39EgziCiNFbIk2FnZp9dB56vtmCKht4t3STDpM0RfC8YlQ2WpGu9okLJYSy1rfynhh0hlOy/9y4cYl50wthoQVxH/Hm9abiTMo2u6xWESreavtdDZ8ByKVltnUrRvzDQ4tVkYwIDAQAB
|
||||
1
alipay/应用私钥.txt
Normal file
@@ -0,0 +1 @@
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCQF8iM6K7y9+Y5KrUFdyMOwCpD7G3AY7RoKfRWuFXNzRPvScNyFpb9MnidbyqVEwGWB9OLTSMMYWJbg0P0kdzhoRLzKINI15oFbiAtCELWKy+k6d3K7hiANz7aUx+CG/37oPy4WX1VLxWTqjsP7lKITsXxoGfj2usvK+yZ03mLp4vEi5j00znz+jTFUbl/eS0ap8UrOG1yU9u3x6Pf0SDOIKI0VsiTYWdmn10Hnq+2YIqG3i3dJMOkzRF8LxiVDZaka72iQslhLLWt/KeGHSGU7L/3LhxiXnTC2GhBXEf8eb1puJMyja7rFYRKt5q+10NnwHIpWW2dStG/MNDi1WRjAgMBAAECggEAHQ8+2fQfPE70djj/su94+YOVwocPB0rUWmGDrm2UmGGwkISezwZxQvUH0DBYNSJVIo3HgwN2ewu0y2HotY0pL7PNX46fE3Sv0kKIaKyO1iR1glvL6B4mgM0jduJmq1W73iB0dzVNCn3paxNcv/S/XlAMqZNBAHnpDmVcXRWCIMDG1yRN3l5NbfgPoQZI4MfAdthjIcIQmEVjNWy69swsdNndj8yrIu9E9RlWly/xqB/BJ6O53i0n+jyviy2grgHxo9VgWKv89qZiczioLT7aAJITpcMofUkGImZy+DHlf+6GU762UkwbLykYN2RRkw5TPvWt4ZUXeON4flr3ig02yQKBgQDMh82rc3TklOZSCw3lwYE58eeYxe9PXpZzcOyrSZZhCs0y528dmwYbm0mPlpVti1MGKnn2eGVKeGQ8a5CbJCi2T+VwIILWM9U2+h82nJW5KD6CYaE86KK/PlzhTGwmpecv8hafkpn51zvyjI3UkKbv/Ep+Mfy89PLumvh5Ze+EfwKBgQC0Wn1o0JCjgCt3K39tahavBuCPDvk7oLA6JLp2W9437YFeuh9L/gYdAtJU79WpmOfgr228cOlExdGGpHqLPSDFpN3ssx1pkUJ6RlTN8YInc+dbAkC6gsm5XR0Jvj4YqghyWHKhxXErnFGDof2EQq7ghHK9pokpBFPowwlzkpOeHQKBgFbqVwJG/COvCvlObUd3pbzECdEoO/wUjAbetBROHzN57Z12MAf6uuu8X9Q+/50fmdaC8nVE0HaHFsF+TGNBSHPBHBU8G51/RVopjF4eyJl4eqfZaTWC/rYagEnVuhfqZIZBcE+7cudzCayXAiaUmfxd0CI0h9yckyfGf1THdrNtAoGASMtNWwTznEqbQJpZ8HuldDe+Y3+TsTGGb7FrYWJrKv+9+9H719xL82G0K3wyLSX+UX39ONYKESwXCdVRcOnXVG7a9DLHaFitEFVa3VThR7NMajtajO1FJoAivFABGEto5V41xn2+0+9gJ1U20i9oDk7nUQzqx5drlsNCCVfcJTECgYEAlEYIQ3AiVqx0RqZjfbg+nyZQmoUfHPASY2Hu9pJWvLwXsQWSPh1gf03galXzZ46wrCaeLygGaoHXW7W8WwsYBR1tG7voQeSe8mmlWgiscmvmvqSowJ10BnsdWwpOTZ8O6rKy8HdyI8gFJyfJgfz+6KekcdGEnQbmwCvB8j+Y7IQ=
|
||||
1
alipay/支付宝公钥.txt
Normal file
@@ -0,0 +1 @@
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjyULL5tuD2cjfNvgn9fvVfn+WbLhoP3wk3bcYb9AabsZc1GCBlnG579Socc3U9dG5IR7C3KHD4kYHnH4tbK2pEWmmfjbVKXWguLqL5K3Dggnl5KVOlVVjrcsmLPqTj7JdeO0AQolmgdr/o6TlhQdsqINQAK5F5wWwIwwcSoJsWZ6zlPPX/Q/eMIN4zGgK2taMhx656zSxsYE5OKRYkTJhVrMktxQdwbUzoFSID++dTpjxF4w5k5qeVW/1WZaaswMFWh2IcJ5oyc+VjTRqZvtQt4gB0Fa0EblfmSJaozhoWHwzwF+1qtv7gp/TcMYj/Ah2+tY0UPxucEcTqY/i7PPfwIDAQAB
|
||||
118
alipay_config.py
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
支付宝支付配置文件
|
||||
电脑网站支付 (alipay.trade.page.pay)
|
||||
"""
|
||||
import os
|
||||
|
||||
# 获取当前文件所在目录
|
||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 支付宝支付配置
|
||||
ALIPAY_CONFIG = {
|
||||
# 应用ID - 从支付宝开放平台获取
|
||||
'app_id': '2021005183650009',
|
||||
|
||||
# 支付宝网关 - 正式环境
|
||||
'gateway_url': 'https://openapi.alipay.com/gateway.do',
|
||||
# 沙箱环境网关(测试用)
|
||||
# 'gateway_url': 'https://openapi-sandbox.dl.alipaydev.com/gateway.do',
|
||||
|
||||
# 签名方式 - 必须使用RSA2
|
||||
'sign_type': 'RSA2',
|
||||
|
||||
# 编码格式
|
||||
'charset': 'utf-8',
|
||||
|
||||
# 返回格式
|
||||
'format': 'json',
|
||||
|
||||
# 密钥文件路径
|
||||
'app_private_key_path': os.path.join(_BASE_DIR, 'alipay', '应用私钥.txt'),
|
||||
'alipay_public_key_path': os.path.join(_BASE_DIR, 'alipay', '支付宝公钥.txt'),
|
||||
|
||||
# 回调地址 - 替换为你的实际域名
|
||||
'notify_url': 'https://api.valuefrontier.cn/api/payment/alipay/callback', # 异步通知地址(后端)
|
||||
'return_url': 'https://valuefrontier.cn/pricing?payment_return=alipay', # 同步跳转地址(前端页面)
|
||||
|
||||
# 产品码 - 电脑网站支付固定为此值
|
||||
'product_code': 'FAST_INSTANT_TRADE_PAY',
|
||||
}
|
||||
|
||||
|
||||
def load_key_from_file(file_path):
|
||||
"""从文件读取密钥内容"""
|
||||
import sys
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
print(f"[AlipayConfig] Key file not found: {file_path}", file=sys.stderr)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[AlipayConfig] Read key file failed: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def get_app_private_key():
|
||||
"""获取应用私钥"""
|
||||
return load_key_from_file(ALIPAY_CONFIG['app_private_key_path'])
|
||||
|
||||
|
||||
def get_alipay_public_key():
|
||||
"""获取支付宝公钥"""
|
||||
return load_key_from_file(ALIPAY_CONFIG['alipay_public_key_path'])
|
||||
|
||||
|
||||
def validate_config():
|
||||
"""验证配置是否完整"""
|
||||
issues = []
|
||||
|
||||
# 检查 app_id
|
||||
if not ALIPAY_CONFIG.get('app_id') or ALIPAY_CONFIG['app_id'].startswith('your_'):
|
||||
issues.append("app_id not configured")
|
||||
|
||||
# 检查密钥文件
|
||||
if not os.path.exists(ALIPAY_CONFIG['app_private_key_path']):
|
||||
issues.append(f"Private key file not found: {ALIPAY_CONFIG['app_private_key_path']}")
|
||||
else:
|
||||
key = get_app_private_key()
|
||||
if not key or len(key) < 100:
|
||||
issues.append("Private key content invalid")
|
||||
|
||||
if not os.path.exists(ALIPAY_CONFIG['alipay_public_key_path']):
|
||||
issues.append(f"Alipay public key file not found: {ALIPAY_CONFIG['alipay_public_key_path']}")
|
||||
else:
|
||||
key = get_alipay_public_key()
|
||||
if not key or len(key) < 100:
|
||||
issues.append("Alipay public key content invalid")
|
||||
|
||||
return len(issues) == 0, issues
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Alipay Payment Config Validation")
|
||||
print("=" * 50)
|
||||
|
||||
is_valid, issues = validate_config()
|
||||
|
||||
if is_valid:
|
||||
print("[OK] Config validation passed!")
|
||||
print(f" App ID: {ALIPAY_CONFIG['app_id']}")
|
||||
print(f" Gateway: {ALIPAY_CONFIG['gateway_url']}")
|
||||
print(f" Notify URL: {ALIPAY_CONFIG['notify_url']}")
|
||||
print(f" Return URL: {ALIPAY_CONFIG['return_url']}")
|
||||
else:
|
||||
print("[ERROR] Config has issues:")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
|
||||
print("\nSetup steps:")
|
||||
print("1. Login to Alipay Open Platform (open.alipay.com)")
|
||||
print("2. Create app and get App ID")
|
||||
print("3. Configure RSA2 keys in development settings")
|
||||
print("4. Put private key and Alipay public key in ./alipay/ folder")
|
||||
print("5. Update config in this file")
|
||||
|
||||
print("=" * 50)
|
||||
523
alipay_pay.py
Normal file
@@ -0,0 +1,523 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
支付宝支付集成模块
|
||||
电脑网站支付 (alipay.trade.page.pay)
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
class AlipayPay:
|
||||
"""支付宝电脑网站支付类"""
|
||||
|
||||
def __init__(self, app_id, app_private_key, alipay_public_key, notify_url, return_url,
|
||||
gateway_url='https://openapi.alipay.com/gateway.do',
|
||||
sign_type='RSA2', charset='utf-8'):
|
||||
"""
|
||||
初始化支付宝支付配置
|
||||
|
||||
Args:
|
||||
app_id: 支付宝应用ID
|
||||
app_private_key: 应用私钥(Base64编码的字符串)
|
||||
alipay_public_key: 支付宝公钥(Base64编码的字符串)
|
||||
notify_url: 异步通知地址
|
||||
return_url: 同步跳转地址
|
||||
gateway_url: 支付宝网关URL
|
||||
sign_type: 签名类型,默认RSA2
|
||||
charset: 编码,默认utf-8
|
||||
"""
|
||||
self.app_id = app_id
|
||||
self.notify_url = notify_url
|
||||
self.return_url = return_url
|
||||
self.gateway_url = gateway_url
|
||||
self.sign_type = sign_type
|
||||
self.charset = charset
|
||||
|
||||
# 加载密钥
|
||||
self.app_private_key = self._load_private_key(app_private_key)
|
||||
self.alipay_public_key = self._load_public_key(alipay_public_key)
|
||||
|
||||
# 注意:不要在这里使用 print,会影响 subprocess 的 JSON 输出
|
||||
|
||||
def _load_private_key(self, key_str):
|
||||
"""加载RSA私钥(支持 PKCS#1 和 PKCS#8 格式)"""
|
||||
try:
|
||||
# 如果密钥不包含头尾,尝试添加PEM格式头尾
|
||||
if '-----BEGIN' not in key_str:
|
||||
# 支付宝通常使用 PKCS#8 格式(MIIEv 开头)
|
||||
# 先尝试 PKCS#8 格式
|
||||
key_str_pkcs8 = f"-----BEGIN PRIVATE KEY-----\n{key_str}\n-----END PRIVATE KEY-----"
|
||||
try:
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_str_pkcs8.encode('utf-8'),
|
||||
password=None,
|
||||
backend=default_backend()
|
||||
)
|
||||
return private_key
|
||||
except Exception:
|
||||
# 如果 PKCS#8 失败,尝试 PKCS#1 格式
|
||||
key_str = f"-----BEGIN RSA PRIVATE KEY-----\n{key_str}\n-----END RSA PRIVATE KEY-----"
|
||||
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_str.encode('utf-8'),
|
||||
password=None,
|
||||
backend=default_backend()
|
||||
)
|
||||
return private_key
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[AlipayPay] Load private key failed: {e}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
def _load_public_key(self, key_str):
|
||||
"""加载RSA公钥"""
|
||||
import sys
|
||||
try:
|
||||
# 如果密钥不包含头尾,添加PEM格式头尾
|
||||
if '-----BEGIN' not in key_str:
|
||||
key_str = f"-----BEGIN PUBLIC KEY-----\n{key_str}\n-----END PUBLIC KEY-----"
|
||||
|
||||
public_key = serialization.load_pem_public_key(
|
||||
key_str.encode('utf-8'),
|
||||
backend=default_backend()
|
||||
)
|
||||
return public_key
|
||||
except Exception as e:
|
||||
print(f"[AlipayPay] Load public key failed: {e}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
def _sign(self, unsigned_string):
|
||||
"""
|
||||
RSA2签名
|
||||
|
||||
Args:
|
||||
unsigned_string: 待签名字符串
|
||||
|
||||
Returns:
|
||||
Base64编码的签名
|
||||
"""
|
||||
import sys
|
||||
try:
|
||||
signature = self.app_private_key.sign(
|
||||
unsigned_string.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256()
|
||||
)
|
||||
return base64.b64encode(signature).decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"[AlipayPay] Sign failed: {e}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
def _verify(self, message, signature):
|
||||
"""
|
||||
验证支付宝签名
|
||||
|
||||
Args:
|
||||
message: 原始消息
|
||||
signature: Base64编码的签名
|
||||
|
||||
Returns:
|
||||
bool: 验证是否通过
|
||||
"""
|
||||
import sys
|
||||
try:
|
||||
self.alipay_public_key.verify(
|
||||
base64.b64decode(signature),
|
||||
message.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256()
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[AlipayPay] Verify signature failed: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def _get_sign_content(self, params):
|
||||
"""
|
||||
获取待签名字符串
|
||||
|
||||
Args:
|
||||
params: 参数字典
|
||||
|
||||
Returns:
|
||||
排序后的参数字符串
|
||||
"""
|
||||
# 过滤空值和sign参数,按key排序
|
||||
filtered_params = {k: v for k, v in params.items()
|
||||
if v is not None and v != '' and k != 'sign'}
|
||||
sorted_params = sorted(filtered_params.items())
|
||||
|
||||
# 拼接字符串
|
||||
sign_content = '&'.join([f'{k}={v}' for k, v in sorted_params])
|
||||
return sign_content
|
||||
|
||||
def create_page_pay_url(self, out_trade_no, total_amount, subject, body=None, timeout_express='30m'):
|
||||
"""
|
||||
创建电脑网站支付URL
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
total_amount: 订单总金额(元,精确到小数点后两位)
|
||||
subject: 订单标题
|
||||
body: 订单描述(可选)
|
||||
timeout_express: 订单超时时间,默认30分钟
|
||||
|
||||
Returns:
|
||||
dict: 包含支付URL的响应
|
||||
"""
|
||||
try:
|
||||
# 金额格式化为两位小数(支付宝要求)
|
||||
formatted_amount = f"{float(total_amount):.2f}"
|
||||
|
||||
# 业务参数
|
||||
biz_content = {
|
||||
'out_trade_no': out_trade_no,
|
||||
'total_amount': formatted_amount,
|
||||
'subject': subject,
|
||||
'product_code': 'FAST_INSTANT_TRADE_PAY', # 电脑网站支付固定值
|
||||
'timeout_express': timeout_express,
|
||||
}
|
||||
if body:
|
||||
biz_content['body'] = body
|
||||
|
||||
# 公共请求参数
|
||||
params = {
|
||||
'app_id': self.app_id,
|
||||
'method': 'alipay.trade.page.pay',
|
||||
'format': 'json',
|
||||
'charset': self.charset,
|
||||
'sign_type': self.sign_type,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'version': '1.0',
|
||||
'notify_url': self.notify_url,
|
||||
'return_url': self.return_url,
|
||||
'biz_content': json.dumps(biz_content, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# 生成签名
|
||||
sign_content = self._get_sign_content(params)
|
||||
params['sign'] = self._sign(sign_content)
|
||||
|
||||
# 构建完整的支付URL
|
||||
pay_url = f"{self.gateway_url}?{urlencode(params)}"
|
||||
|
||||
# 日志输出到 stderr,避免影响 subprocess JSON 输出
|
||||
import sys
|
||||
print(f"[AlipayPay] Order created: {out_trade_no}, amount: {formatted_amount}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'pay_url': pay_url,
|
||||
'order_no': out_trade_no
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[AlipayPay] Create order failed: {e}", file=sys.stderr)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def create_wap_pay_url(self, out_trade_no, total_amount, subject, body=None, timeout_express='30m', quit_url=None):
|
||||
"""
|
||||
创建手机网站支付URL (H5支付,可调起手机支付宝APP)
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
total_amount: 订单总金额(元,精确到小数点后两位)
|
||||
subject: 订单标题
|
||||
body: 订单描述(可选)
|
||||
timeout_express: 订单超时时间,默认30分钟
|
||||
quit_url: 用户付款中途退出返回商户网站的地址(可选)
|
||||
|
||||
Returns:
|
||||
dict: 包含支付URL的响应
|
||||
"""
|
||||
try:
|
||||
# 金额格式化为两位小数(支付宝要求)
|
||||
formatted_amount = f"{float(total_amount):.2f}"
|
||||
|
||||
# 业务参数
|
||||
biz_content = {
|
||||
'out_trade_no': out_trade_no,
|
||||
'total_amount': formatted_amount,
|
||||
'subject': subject,
|
||||
'product_code': 'QUICK_WAP_WAY', # 手机网站支付固定值
|
||||
'timeout_express': timeout_express,
|
||||
}
|
||||
if body:
|
||||
biz_content['body'] = body
|
||||
if quit_url:
|
||||
biz_content['quit_url'] = quit_url
|
||||
|
||||
# 公共请求参数
|
||||
params = {
|
||||
'app_id': self.app_id,
|
||||
'method': 'alipay.trade.wap.pay', # 手机网站支付接口
|
||||
'format': 'json',
|
||||
'charset': self.charset,
|
||||
'sign_type': self.sign_type,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'version': '1.0',
|
||||
'notify_url': self.notify_url,
|
||||
'return_url': self.return_url,
|
||||
'biz_content': json.dumps(biz_content, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# 生成签名
|
||||
sign_content = self._get_sign_content(params)
|
||||
params['sign'] = self._sign(sign_content)
|
||||
|
||||
# 构建完整的支付URL
|
||||
pay_url = f"{self.gateway_url}?{urlencode(params)}"
|
||||
|
||||
# 日志输出到 stderr,避免影响 subprocess JSON 输出
|
||||
import sys
|
||||
print(f"[AlipayPay] WAP Order created: {out_trade_no}, amount: {formatted_amount}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'pay_url': pay_url,
|
||||
'order_no': out_trade_no,
|
||||
'pay_type': 'wap' # 标识为手机网站支付
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[AlipayPay] Create WAP order failed: {e}", file=sys.stderr)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def query_order(self, out_trade_no=None, trade_no=None):
|
||||
"""
|
||||
查询订单状态
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
trade_no: 支付宝交易号
|
||||
|
||||
Returns:
|
||||
dict: 订单状态信息
|
||||
"""
|
||||
import requests
|
||||
|
||||
try:
|
||||
if not out_trade_no and not trade_no:
|
||||
return {'success': False, 'error': '订单号和交易号不能同时为空'}
|
||||
|
||||
# 业务参数
|
||||
biz_content = {}
|
||||
if out_trade_no:
|
||||
biz_content['out_trade_no'] = out_trade_no
|
||||
if trade_no:
|
||||
biz_content['trade_no'] = trade_no
|
||||
|
||||
# 公共请求参数
|
||||
params = {
|
||||
'app_id': self.app_id,
|
||||
'method': 'alipay.trade.query',
|
||||
'format': 'json',
|
||||
'charset': self.charset,
|
||||
'sign_type': self.sign_type,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'version': '1.0',
|
||||
'biz_content': json.dumps(biz_content, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# 生成签名
|
||||
sign_content = self._get_sign_content(params)
|
||||
params['sign'] = self._sign(sign_content)
|
||||
|
||||
# 发送请求
|
||||
response = requests.get(self.gateway_url, params=params, timeout=30)
|
||||
result = response.json()
|
||||
|
||||
# 日志输出到 stderr
|
||||
import sys
|
||||
print(f"[AlipayPay] Query response: {result}", file=sys.stderr)
|
||||
|
||||
# 解析响应
|
||||
query_response = result.get('alipay_trade_query_response', {})
|
||||
if query_response.get('code') == '10000':
|
||||
trade_status = query_response.get('trade_status')
|
||||
return {
|
||||
'success': True,
|
||||
'trade_status': trade_status,
|
||||
'trade_no': query_response.get('trade_no'),
|
||||
'out_trade_no': query_response.get('out_trade_no'),
|
||||
'total_amount': query_response.get('total_amount'),
|
||||
'buyer_logon_id': query_response.get('buyer_logon_id'),
|
||||
'send_pay_date': query_response.get('send_pay_date'),
|
||||
# 状态映射
|
||||
'is_paid': trade_status == 'TRADE_SUCCESS' or trade_status == 'TRADE_FINISHED',
|
||||
'is_closed': trade_status == 'TRADE_CLOSED',
|
||||
'is_waiting': trade_status == 'WAIT_BUYER_PAY',
|
||||
}
|
||||
else:
|
||||
error_msg = query_response.get('sub_msg') or query_response.get('msg', '查询失败')
|
||||
return {
|
||||
'success': False,
|
||||
'error': error_msg,
|
||||
'code': query_response.get('code'),
|
||||
'sub_code': query_response.get('sub_code')
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
import sys
|
||||
print(f"[AlipayPay] API request failed: {e}", file=sys.stderr)
|
||||
return {'success': False, 'error': f'网络请求失败: {e}'}
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[AlipayPay] Query order error: {e}", file=sys.stderr)
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def verify_callback(self, params):
|
||||
"""
|
||||
验证支付宝异步回调
|
||||
|
||||
Args:
|
||||
params: 回调参数字典
|
||||
|
||||
Returns:
|
||||
dict: 验证结果
|
||||
"""
|
||||
try:
|
||||
# 获取签名
|
||||
sign = params.pop('sign', None)
|
||||
sign_type = params.pop('sign_type', 'RSA2')
|
||||
|
||||
if not sign:
|
||||
return {'success': False, 'error': '缺少签名参数'}
|
||||
|
||||
# 构建待验签字符串
|
||||
sign_content = self._get_sign_content(params)
|
||||
|
||||
# 验证签名
|
||||
import sys
|
||||
if self._verify(sign_content, sign):
|
||||
print(f"[AlipayPay] Callback signature verified", file=sys.stderr)
|
||||
return {
|
||||
'success': True,
|
||||
'data': params
|
||||
}
|
||||
else:
|
||||
print(f"[AlipayPay] Callback signature verification failed", file=sys.stderr)
|
||||
return {
|
||||
'success': False,
|
||||
'error': '签名验证失败'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AlipayPay] Verify callback error: {e}", file=sys.stderr)
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def verify_return(self, params):
|
||||
"""
|
||||
验证支付宝同步返回(用户支付后跳转回来)
|
||||
|
||||
Args:
|
||||
params: 同步返回参数字典
|
||||
|
||||
Returns:
|
||||
dict: 验证结果
|
||||
"""
|
||||
# 同步返回的验签逻辑与异步回调相同
|
||||
return self.verify_callback(params)
|
||||
|
||||
|
||||
# 工厂函数
|
||||
def create_alipay_instance():
|
||||
"""创建支付宝支付实例"""
|
||||
try:
|
||||
from alipay_config import ALIPAY_CONFIG, get_app_private_key, get_alipay_public_key, validate_config
|
||||
|
||||
# 验证配置
|
||||
is_valid, issues = validate_config()
|
||||
if not is_valid:
|
||||
raise ValueError(f"支付宝配置错误: {'; '.join(issues)}")
|
||||
|
||||
# 获取密钥
|
||||
app_private_key = get_app_private_key()
|
||||
alipay_public_key = get_alipay_public_key()
|
||||
|
||||
if not app_private_key or not alipay_public_key:
|
||||
raise ValueError("密钥加载失败")
|
||||
|
||||
return AlipayPay(
|
||||
app_id=ALIPAY_CONFIG['app_id'],
|
||||
app_private_key=app_private_key,
|
||||
alipay_public_key=alipay_public_key,
|
||||
notify_url=ALIPAY_CONFIG['notify_url'],
|
||||
return_url=ALIPAY_CONFIG['return_url'],
|
||||
gateway_url=ALIPAY_CONFIG['gateway_url'],
|
||||
sign_type=ALIPAY_CONFIG['sign_type'],
|
||||
charset=ALIPAY_CONFIG['charset']
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
raise ValueError(f"无法导入支付宝配置: {e}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"创建支付宝实例失败: {e}")
|
||||
|
||||
|
||||
def check_alipay_ready():
|
||||
"""检查支付宝支付是否就绪"""
|
||||
try:
|
||||
instance = create_alipay_instance()
|
||||
return True, "支付宝支付配置正确"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
"""测试代码"""
|
||||
import sys
|
||||
|
||||
print("=" * 60)
|
||||
print("Alipay Payment Test")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# 检查配置
|
||||
is_ready, message = check_alipay_ready()
|
||||
print(f"\nConfig status: {'READY' if is_ready else 'NOT READY'}")
|
||||
print(f"Details: {message}")
|
||||
|
||||
if is_ready:
|
||||
# 创建实例
|
||||
alipay = create_alipay_instance()
|
||||
|
||||
# 测试创建订单
|
||||
test_order_no = f"TEST{int(time.time())}"
|
||||
result = alipay.create_page_pay_url(
|
||||
out_trade_no=test_order_no,
|
||||
total_amount='0.01',
|
||||
subject='Test Product',
|
||||
body='This is a test order'
|
||||
)
|
||||
|
||||
print(f"\nCreate order result:")
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
if result['success']:
|
||||
print(f"\nPayment URL (open in browser):")
|
||||
print(result['pay_url'][:200] + '...')
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nTest failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
163
alipay_pay_worker.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
支付宝支付工作脚本
|
||||
用于在 subprocess 中执行,绕过 eventlet DNS 问题
|
||||
|
||||
用法:
|
||||
python alipay_pay_worker.py check # 检查配置
|
||||
python alipay_pay_worker.py create <order_no> <amount> <subject> [body] [pay_type] # 创建订单
|
||||
pay_type: page=电脑网站支付(默认), wap=手机网站支付
|
||||
python alipay_pay_worker.py query <order_no> # 查询订单
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
|
||||
|
||||
def check_config():
|
||||
"""检查支付宝配置"""
|
||||
try:
|
||||
from alipay_pay import check_alipay_ready
|
||||
is_ready, message = check_alipay_ready()
|
||||
return {
|
||||
'success': is_ready,
|
||||
'message': message if is_ready else None,
|
||||
'error': None if is_ready else message
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def create_order(order_no, amount, subject, body=None, pay_type='page'):
|
||||
"""创建支付宝订单
|
||||
|
||||
Args:
|
||||
order_no: 订单号
|
||||
amount: 金额
|
||||
subject: 标题
|
||||
body: 描述
|
||||
pay_type: 支付类型 'page'=电脑网站支付, 'wap'=手机网站支付
|
||||
"""
|
||||
try:
|
||||
from alipay_pay import create_alipay_instance
|
||||
alipay = create_alipay_instance()
|
||||
|
||||
if pay_type == 'wap':
|
||||
# 手机网站支付
|
||||
result = alipay.create_wap_pay_url(
|
||||
out_trade_no=order_no,
|
||||
total_amount=str(amount),
|
||||
subject=subject,
|
||||
body=body,
|
||||
timeout_express='30m',
|
||||
quit_url='https://valuefrontier.cn/pricing' # 用户取消支付时返回的页面
|
||||
)
|
||||
else:
|
||||
# 电脑网站支付(默认)
|
||||
result = alipay.create_page_pay_url(
|
||||
out_trade_no=order_no,
|
||||
total_amount=str(amount),
|
||||
subject=subject,
|
||||
body=body,
|
||||
timeout_express='30m'
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def query_order(order_no):
|
||||
"""查询支付宝订单"""
|
||||
try:
|
||||
from alipay_pay import create_alipay_instance
|
||||
alipay = create_alipay_instance()
|
||||
|
||||
result = alipay.query_order(out_trade_no=order_no)
|
||||
|
||||
# 转换状态为统一格式
|
||||
if result.get('success'):
|
||||
trade_status = result.get('trade_status')
|
||||
# 映射支付宝状态到统一状态
|
||||
if trade_status in ['TRADE_SUCCESS', 'TRADE_FINISHED']:
|
||||
result['trade_state'] = 'SUCCESS'
|
||||
elif trade_status == 'WAIT_BUYER_PAY':
|
||||
result['trade_state'] = 'NOTPAY'
|
||||
elif trade_status == 'TRADE_CLOSED':
|
||||
result['trade_state'] = 'CLOSED'
|
||||
else:
|
||||
result['trade_state'] = trade_status
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({
|
||||
'success': False,
|
||||
'error': '缺少命令参数。用法: check | create <order_no> <amount> <subject> | query <order_no>'
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1].lower()
|
||||
|
||||
try:
|
||||
if command == 'check':
|
||||
result = check_config()
|
||||
|
||||
elif command == 'create':
|
||||
if len(sys.argv) < 5:
|
||||
result = {
|
||||
'success': False,
|
||||
'error': '创建订单需要参数: order_no, amount, subject'
|
||||
}
|
||||
else:
|
||||
order_no = sys.argv[2]
|
||||
amount = sys.argv[3]
|
||||
subject = sys.argv[4]
|
||||
body = sys.argv[5] if len(sys.argv) > 5 else None
|
||||
# pay_type: page=电脑网站支付, wap=手机网站支付
|
||||
pay_type = sys.argv[6] if len(sys.argv) > 6 else 'page'
|
||||
result = create_order(order_no, amount, subject, body, pay_type)
|
||||
|
||||
elif command == 'query':
|
||||
if len(sys.argv) < 3:
|
||||
result = {
|
||||
'success': False,
|
||||
'error': '查询订单需要参数: order_no'
|
||||
}
|
||||
else:
|
||||
order_no = sys.argv[2]
|
||||
result = query_order(order_no)
|
||||
|
||||
else:
|
||||
result = {
|
||||
'success': False,
|
||||
'error': f'未知命令: {command}'
|
||||
}
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -12,6 +12,7 @@ import openai
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from elasticsearch import Elasticsearch
|
||||
from datetime import datetime, date
|
||||
@@ -41,7 +42,7 @@ HIERARCHY_FILE = 'concept_hierarchy_v3.json'
|
||||
|
||||
# MySQL配置
|
||||
MYSQL_CONFIG = {
|
||||
'host': '192.168.1.8',
|
||||
'host': '192.168.1.5',
|
||||
'port': 3306,
|
||||
'user': 'root',
|
||||
'password': 'Zzl5588161!',
|
||||
@@ -186,6 +187,14 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# ==================== 微信小程序验证 ====================
|
||||
|
||||
@app.get("/DfASFmNQoo.txt", response_class=PlainTextResponse)
|
||||
async def wechat_verification():
|
||||
"""微信小程序域名验证文件"""
|
||||
return "ebd78eb22819b1393a34c6ae1e8fcce6"
|
||||
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class HierarchyInfo(BaseModel):
|
||||
@@ -309,7 +318,11 @@ def generate_embedding(text: str) -> List[float]:
|
||||
|
||||
def calculate_semantic_weight(query: str) -> float:
|
||||
"""根据查询长度动态计算语义权重"""
|
||||
query_length = len(query)
|
||||
# 空查询不需要语义搜索
|
||||
if not query or not query.strip():
|
||||
return 0.0
|
||||
|
||||
query_length = len(query.strip())
|
||||
|
||||
if query_length < 10:
|
||||
return 0.3
|
||||
@@ -321,6 +334,90 @@ def calculate_semantic_weight(query: str) -> float:
|
||||
return 0.7
|
||||
|
||||
|
||||
async def get_top_concepts_by_change(
|
||||
trade_date: Optional[date],
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
filter_lv1: Optional[str] = None,
|
||||
filter_lv2: Optional[str] = None
|
||||
) -> tuple[List[Dict], int, date]:
|
||||
"""
|
||||
直接从 MySQL 获取涨跌幅排序的概念列表(用于空查询优化)
|
||||
返回: (概念列表, 总数, 实际查询日期)
|
||||
"""
|
||||
if not mysql_pool:
|
||||
return [], 0, None
|
||||
|
||||
try:
|
||||
async with mysql_pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 获取交易日期
|
||||
query_date = trade_date
|
||||
if query_date is None:
|
||||
await cursor.execute("SELECT MAX(trade_date) as max_date FROM concept_daily_stats WHERE concept_type = 'leaf'")
|
||||
result = await cursor.fetchone()
|
||||
if not result or not result['max_date']:
|
||||
return [], 0, None
|
||||
query_date = result['max_date']
|
||||
|
||||
# 构建基础条件
|
||||
where_conditions = ["trade_date = %s", "concept_type = 'leaf'"]
|
||||
params = [query_date]
|
||||
|
||||
# 添加层级过滤(通过 concept_name 关联)
|
||||
# 注意:这里需要根据 concept_to_hierarchy 过滤
|
||||
if filter_lv1 or filter_lv2:
|
||||
# 获取符合层级条件的概念名称
|
||||
filtered_concepts = []
|
||||
for concept_name, hierarchy in concept_to_hierarchy.items():
|
||||
if filter_lv1 and hierarchy.get('lv1') != filter_lv1:
|
||||
continue
|
||||
if filter_lv2 and hierarchy.get('lv2') != filter_lv2:
|
||||
continue
|
||||
filtered_concepts.append(concept_name)
|
||||
|
||||
if not filtered_concepts:
|
||||
return [], 0, query_date
|
||||
|
||||
placeholders = ','.join(['%s'] * len(filtered_concepts))
|
||||
where_conditions.append(f"concept_name IN ({placeholders})")
|
||||
params.extend(filtered_concepts)
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
# 查询总数
|
||||
count_query = f"SELECT COUNT(*) as cnt FROM concept_daily_stats WHERE {where_clause}"
|
||||
await cursor.execute(count_query, params)
|
||||
total = (await cursor.fetchone())['cnt']
|
||||
|
||||
# 查询数据(按涨跌幅降序)
|
||||
data_query = f"""
|
||||
SELECT concept_id, concept_name, avg_change_pct, stock_count
|
||||
FROM concept_daily_stats
|
||||
WHERE {where_clause}
|
||||
ORDER BY avg_change_pct DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
await cursor.execute(data_query, params + [limit, offset])
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
concepts = []
|
||||
for row in rows:
|
||||
concepts.append({
|
||||
'concept_id': row['concept_id'],
|
||||
'concept_name': row['concept_name'],
|
||||
'avg_change_pct': float(row['avg_change_pct']) if row['avg_change_pct'] else None,
|
||||
'stock_count': row['stock_count'],
|
||||
'trade_date': query_date
|
||||
})
|
||||
|
||||
return concepts, total, query_date
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取涨跌幅排序概念失败: {e}")
|
||||
return [], 0, None
|
||||
|
||||
|
||||
async def get_concept_price_data(concept_ids: List[str], trade_date: Optional[date] = None) -> Dict[str, ConceptPriceInfo]:
|
||||
"""获取概念的涨跌幅数据"""
|
||||
if not mysql_pool or not concept_ids:
|
||||
@@ -451,10 +548,117 @@ async def root():
|
||||
async def search_concepts(request: SearchRequest):
|
||||
"""
|
||||
搜索概念 - 支持语义搜索和层级过滤
|
||||
|
||||
优化:空查询 + 涨跌幅排序时,直接从 MySQL 查询,避免 ES 大量数据获取
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# ========== 优化路径:空查询 + 涨跌幅排序 ==========
|
||||
# 这是概念中心首页的默认场景,直接从 MySQL 获取排序结果
|
||||
is_empty_query = not request.query or not request.query.strip()
|
||||
is_change_sort = request.sort_by == "change_pct"
|
||||
no_stock_filter = not request.filter_stocks
|
||||
|
||||
if is_empty_query and is_change_sort and no_stock_filter:
|
||||
logger.info(f"[Search优化] 使用 MySQL 快速路径: page={request.page}, size={request.size}")
|
||||
|
||||
# 计算分页
|
||||
offset = (request.page - 1) * request.size
|
||||
|
||||
# 从 MySQL 获取涨跌幅排序的概念
|
||||
top_concepts, total, actual_date = await get_top_concepts_by_change(
|
||||
trade_date=request.trade_date,
|
||||
limit=request.size,
|
||||
offset=offset,
|
||||
filter_lv1=request.filter_lv1,
|
||||
filter_lv2=request.filter_lv2
|
||||
)
|
||||
|
||||
if not top_concepts:
|
||||
took_ms = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
return SearchResponse(
|
||||
total=0,
|
||||
took_ms=took_ms,
|
||||
results=[],
|
||||
search_info={"query": "", "semantic_weight": 0, "match_type": "mysql_optimized"},
|
||||
price_date=actual_date,
|
||||
page=request.page,
|
||||
total_pages=0
|
||||
)
|
||||
|
||||
# 用 concept_id 从 ES 批量获取详细信息
|
||||
concept_ids = [c['concept_id'] for c in top_concepts]
|
||||
es_body = {
|
||||
"query": {"terms": {"concept_id": concept_ids}},
|
||||
"size": len(concept_ids),
|
||||
"_source": {"excludes": ["description_embedding", "insight"]}
|
||||
}
|
||||
es_response = es_client.search(index=INDEX_NAME, body=es_body, timeout="10s")
|
||||
|
||||
# 构建 concept_id -> ES 详情的映射
|
||||
es_details = {}
|
||||
for hit in es_response['hits']['hits']:
|
||||
source = hit['_source']
|
||||
es_details[source.get('concept_id', '')] = source
|
||||
|
||||
# 组装结果(保持 MySQL 的排序顺序)
|
||||
results = []
|
||||
for mysql_concept in top_concepts:
|
||||
cid = mysql_concept['concept_id']
|
||||
es_data = es_details.get(cid, {})
|
||||
concept_name = mysql_concept['concept_name']
|
||||
|
||||
# 解析股票
|
||||
stocks_list, _ = parse_stocks_from_es(es_data)
|
||||
|
||||
# 获取层级信息
|
||||
hierarchy_info = get_concept_hierarchy(concept_name)
|
||||
hierarchy = HierarchyInfo(**hierarchy_info) if hierarchy_info else None
|
||||
|
||||
result = ConceptResult(
|
||||
concept_id=cid,
|
||||
concept=concept_name,
|
||||
description=es_data.get('description'),
|
||||
tags=es_data.get('tags', []),
|
||||
outbreak_dates=es_data.get('outbreak_dates', []),
|
||||
stocks=stocks_list,
|
||||
stock_count=mysql_concept['stock_count'] or len(es_data.get('stocks', [])),
|
||||
hierarchy=hierarchy,
|
||||
score=0.0,
|
||||
match_type="mysql_optimized",
|
||||
highlights=None,
|
||||
price_info=ConceptPriceInfo(
|
||||
trade_date=actual_date,
|
||||
avg_change_pct=mysql_concept['avg_change_pct']
|
||||
)
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
took_ms = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
total_pages = max(1, (total + request.size - 1) // request.size)
|
||||
|
||||
logger.info(f"[Search优化] MySQL 快速路径完成: took={took_ms}ms, total={total}, returned={len(results)}")
|
||||
|
||||
return SearchResponse(
|
||||
total=total,
|
||||
took_ms=took_ms,
|
||||
results=results,
|
||||
search_info={
|
||||
"query": "",
|
||||
"semantic_weight": 0,
|
||||
"match_type": "mysql_optimized",
|
||||
"filter_lv1": request.filter_lv1,
|
||||
"filter_lv2": request.filter_lv2,
|
||||
"sort_by": request.sort_by
|
||||
},
|
||||
price_date=actual_date,
|
||||
page=request.page,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
# ========== 常规路径:有搜索词或其他排序方式 ==========
|
||||
|
||||
# 计算语义权重
|
||||
if request.semantic_weight is not None:
|
||||
semantic_weight = request.semantic_weight
|
||||
@@ -469,9 +673,11 @@ async def search_concepts(request: SearchRequest):
|
||||
semantic_weight = 0
|
||||
|
||||
# 确定搜索数量
|
||||
# 修复:当按涨跌幅排序时,需要获取全部概念(800+)再排序
|
||||
# 原来限制 500 会导致排序不准确
|
||||
effective_search_size = request.search_size
|
||||
if request.sort_by in ["change_pct", "outbreak_date"]:
|
||||
effective_search_size = min(500, request.search_size * 5)
|
||||
effective_search_size = min(1000, request.search_size * 10)
|
||||
|
||||
# 构建查询
|
||||
search_body = {}
|
||||
|
||||
64
email_sender.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
独立的邮件发送脚本(绕过 eventlet DNS 问题)
|
||||
|
||||
使用方式:
|
||||
python email_sender.py <to_email> <subject> <body> <smtp_server> <smtp_port> <username> <password> <use_ssl>
|
||||
|
||||
返回值:
|
||||
成功返回 0,失败返回 1
|
||||
"""
|
||||
|
||||
import sys
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
|
||||
def send_email(to_email, subject, body, smtp_server, smtp_port, username, password, use_ssl):
|
||||
"""发送邮件"""
|
||||
try:
|
||||
# 创建邮件
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = username
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||
|
||||
# 连接 SMTP 服务器
|
||||
if use_ssl:
|
||||
server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
|
||||
else:
|
||||
server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
|
||||
server.starttls()
|
||||
|
||||
# 登录并发送
|
||||
server.login(username, password)
|
||||
server.sendmail(username, [to_email], msg.as_string())
|
||||
server.quit()
|
||||
|
||||
print(f"Email sent successfully to {to_email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Email Error: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 9:
|
||||
print("Usage: python email_sender.py <to_email> <subject> <body> <smtp_server> <smtp_port> <username> <password> <use_ssl>")
|
||||
sys.exit(1)
|
||||
|
||||
to_email = sys.argv[1]
|
||||
subject = sys.argv[2]
|
||||
body = sys.argv[3]
|
||||
smtp_server = sys.argv[4]
|
||||
smtp_port = int(sys.argv[5])
|
||||
username = sys.argv[6]
|
||||
password = sys.argv[7]
|
||||
use_ssl = sys.argv[8].lower() == 'true'
|
||||
|
||||
success = send_email(to_email, subject, body, smtp_server, smtp_port, username, password, use_ssl)
|
||||
sys.exit(0 if success else 1)
|
||||
189
export_concept_data.py
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
概念涨跌幅数据导出脚本
|
||||
从 MySQL 导出最新的热门概念数据到静态 JSON 文件
|
||||
|
||||
使用方法:
|
||||
python export_concept_data.py # 导出最新数据
|
||||
python export_concept_data.py --limit 100 # 限制导出数量
|
||||
|
||||
输出:public/data/concept/latest.json
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import pymysql
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
# 配置
|
||||
MYSQL_CONFIG = {
|
||||
'host': '192.168.1.5',
|
||||
'port': 3306,
|
||||
'user': 'root',
|
||||
'password': 'Zzl5588161!',
|
||||
'db': 'stock',
|
||||
'charset': 'utf8mb4',
|
||||
}
|
||||
|
||||
# 输出文件路径
|
||||
OUTPUT_FILE = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
'public', 'data', 'concept', 'latest.json'
|
||||
)
|
||||
|
||||
# 层级结构文件
|
||||
HIERARCHY_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'concept_hierarchy_v3.json')
|
||||
|
||||
# 日志配置
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 层级映射缓存
|
||||
concept_to_hierarchy = {}
|
||||
|
||||
|
||||
def load_hierarchy():
|
||||
"""加载层级结构"""
|
||||
global concept_to_hierarchy
|
||||
|
||||
if not os.path.exists(HIERARCHY_FILE):
|
||||
logger.warning(f"层级文件不存在: {HIERARCHY_FILE}")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(HIERARCHY_FILE, 'r', encoding='utf-8') as f:
|
||||
hierarchy_data = json.load(f)
|
||||
|
||||
for lv1 in hierarchy_data.get('hierarchy', []):
|
||||
lv1_name = lv1.get('lv1', '')
|
||||
lv1_id = lv1.get('lv1_id', '')
|
||||
|
||||
for child in lv1.get('children', []):
|
||||
lv2_name = child.get('lv2', '')
|
||||
lv2_id = child.get('lv2_id', '')
|
||||
|
||||
if 'children' in child:
|
||||
for lv3_child in child.get('children', []):
|
||||
lv3_name = lv3_child.get('lv3', '')
|
||||
lv3_id = lv3_child.get('lv3_id', '')
|
||||
|
||||
for concept in lv3_child.get('concepts', []):
|
||||
concept_to_hierarchy[concept] = {
|
||||
'lv1': lv1_name,
|
||||
'lv1_id': lv1_id,
|
||||
'lv2': lv2_name,
|
||||
'lv2_id': lv2_id,
|
||||
'lv3': lv3_name,
|
||||
'lv3_id': lv3_id
|
||||
}
|
||||
else:
|
||||
for concept in child.get('concepts', []):
|
||||
concept_to_hierarchy[concept] = {
|
||||
'lv1': lv1_name,
|
||||
'lv1_id': lv1_id,
|
||||
'lv2': lv2_name,
|
||||
'lv2_id': lv2_id,
|
||||
'lv3': None,
|
||||
'lv3_id': None
|
||||
}
|
||||
|
||||
logger.info(f"加载层级结构完成,共 {len(concept_to_hierarchy)} 个概念")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"加载层级结构失败: {e}")
|
||||
|
||||
|
||||
def get_connection():
|
||||
"""获取数据库连接"""
|
||||
return pymysql.connect(**MYSQL_CONFIG)
|
||||
|
||||
|
||||
def export_latest(limit=100):
|
||||
"""导出最新的热门概念数据"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
|
||||
# 获取最新交易日期
|
||||
cursor.execute("""
|
||||
SELECT MAX(trade_date) as max_date
|
||||
FROM concept_daily_stats
|
||||
WHERE concept_type = 'leaf'
|
||||
""")
|
||||
result = cursor.fetchone()
|
||||
if not result or not result['max_date']:
|
||||
logger.error("无可用数据")
|
||||
return None
|
||||
|
||||
trade_date = result['max_date']
|
||||
logger.info(f"最新交易日期: {trade_date}")
|
||||
|
||||
# 按涨跌幅降序获取概念列表
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
concept_id,
|
||||
concept_name,
|
||||
concept_type,
|
||||
trade_date,
|
||||
avg_change_pct,
|
||||
stock_count
|
||||
FROM concept_daily_stats
|
||||
WHERE trade_date = %s AND concept_type = 'leaf'
|
||||
ORDER BY avg_change_pct DESC
|
||||
LIMIT %s
|
||||
""", (trade_date, limit))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
concepts = []
|
||||
for row in rows:
|
||||
concept_name = row['concept_name']
|
||||
hierarchy = concept_to_hierarchy.get(concept_name)
|
||||
|
||||
concepts.append({
|
||||
'concept_id': row['concept_id'],
|
||||
'concept': concept_name,
|
||||
'price_info': {
|
||||
'trade_date': row['trade_date'].strftime('%Y-%m-%d'),
|
||||
'avg_change_pct': float(row['avg_change_pct']) if row['avg_change_pct'] else None
|
||||
},
|
||||
'stock_count': row['stock_count'],
|
||||
'hierarchy': hierarchy
|
||||
})
|
||||
|
||||
data = {
|
||||
'trade_date': trade_date.strftime('%Y-%m-%d'),
|
||||
'total': len(concepts),
|
||||
'results': concepts,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 确保目录存在
|
||||
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
|
||||
|
||||
# 保存文件
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"已保存: {OUTPUT_FILE} ({len(concepts)} 个概念)")
|
||||
return data
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='导出热门概念涨跌幅数据')
|
||||
parser.add_argument('--limit', type=int, default=100, help='导出的概念数量限制')
|
||||
args = parser.parse_args()
|
||||
|
||||
load_hierarchy()
|
||||
export_latest(args.limit)
|
||||
logger.info("导出完成!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
342
export_zt_data.py
Normal file
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
涨停分析数据导出脚本
|
||||
从 Elasticsearch 导出数据到静态 JSON 文件,供前端直接读取
|
||||
|
||||
使用方法:
|
||||
python export_zt_data.py # 导出最近 30 天数据
|
||||
python export_zt_data.py --days 7 # 导出最近 7 天
|
||||
python export_zt_data.py --date 20251212 # 导出指定日期
|
||||
python export_zt_data.py --all # 导出所有数据
|
||||
|
||||
输出目录:data/zt/
|
||||
├── dates.json # 可用日期列表
|
||||
├── daily/
|
||||
│ └── {date}.json # 每日分析数据
|
||||
└── stocks.jsonl # 所有股票记录(用于关键词搜索)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from elasticsearch import Elasticsearch
|
||||
import logging
|
||||
|
||||
# 配置
|
||||
ES_HOST = os.environ.get('ES_HOST', 'http://127.0.0.1:9200')
|
||||
# 输出到 public 目录,这样前端可以直接访问
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'public', 'data', 'zt')
|
||||
|
||||
# 日志配置
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ES 连接
|
||||
es = Elasticsearch([ES_HOST], timeout=60, retry_on_timeout=True, max_retries=3)
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
"""确保输出目录存在"""
|
||||
os.makedirs(os.path.join(OUTPUT_DIR, 'daily'), exist_ok=True)
|
||||
logger.info(f"输出目录: {OUTPUT_DIR}")
|
||||
|
||||
|
||||
def get_available_dates():
|
||||
"""获取所有可用日期"""
|
||||
query = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
"dates": {
|
||||
"terms": {
|
||||
"field": "date",
|
||||
"size": 10000,
|
||||
"order": {"_key": "desc"}
|
||||
},
|
||||
"aggs": {
|
||||
"stock_count": {
|
||||
"cardinality": {"field": "scode"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = es.search(index="zt_stocks", body=query)
|
||||
|
||||
dates = []
|
||||
for bucket in result['aggregations']['dates']['buckets']:
|
||||
date = bucket['key']
|
||||
count = bucket['doc_count']
|
||||
# 格式化日期 YYYYMMDD -> YYYY-MM-DD
|
||||
formatted = f"{date[:4]}-{date[4:6]}-{date[6:]}"
|
||||
dates.append({
|
||||
'date': date,
|
||||
'formatted_date': formatted,
|
||||
'count': count
|
||||
})
|
||||
|
||||
return dates
|
||||
|
||||
|
||||
def get_daily_stats(date):
|
||||
"""获取指定日期的统计数据"""
|
||||
query = {
|
||||
"query": {"term": {"date": date}},
|
||||
"_source": ["sector_stats", "word_freq", "chart_data"]
|
||||
}
|
||||
|
||||
result = es.search(index="zt_daily_stats", body=query, size=1)
|
||||
|
||||
if result['hits']['total']['value'] > 0:
|
||||
return result['hits']['hits'][0]['_source']
|
||||
return {}
|
||||
|
||||
|
||||
def get_daily_stocks(date):
|
||||
"""获取指定日期的所有股票"""
|
||||
query = {
|
||||
"query": {"term": {"date": date}},
|
||||
"size": 10000,
|
||||
"sort": [{"zt_time": "asc"}],
|
||||
"_source": {
|
||||
"exclude": ["content_embedding"] # 排除向量字段
|
||||
}
|
||||
}
|
||||
|
||||
result = es.search(index="zt_stocks", body=query)
|
||||
|
||||
stocks = []
|
||||
for hit in result['hits']['hits']:
|
||||
stock = hit['_source']
|
||||
# 格式化涨停时间
|
||||
if 'zt_time' in stock:
|
||||
try:
|
||||
zt_time = datetime.fromisoformat(stock['zt_time'].replace('Z', '+00:00'))
|
||||
stock['formatted_time'] = zt_time.strftime('%H:%M:%S')
|
||||
except:
|
||||
stock['formatted_time'] = ''
|
||||
stocks.append(stock)
|
||||
|
||||
return stocks
|
||||
|
||||
|
||||
def process_sector_data(sector_stats, stocks):
|
||||
"""处理板块数据"""
|
||||
if sector_stats:
|
||||
# 从预计算的 sector_stats 生成
|
||||
sector_data = {}
|
||||
for sector_info in sector_stats:
|
||||
sector_name = sector_info['sector_name']
|
||||
sector_data[sector_name] = {
|
||||
'count': sector_info['count'],
|
||||
'stock_codes': sector_info.get('stock_codes', [])
|
||||
}
|
||||
else:
|
||||
# 从股票数据生成
|
||||
sector_stocks = defaultdict(list)
|
||||
sector_counts = defaultdict(int)
|
||||
|
||||
for stock in stocks:
|
||||
for sector in stock.get('core_sectors', []):
|
||||
sector_counts[sector] += 1
|
||||
|
||||
small_sectors = {s for s, c in sector_counts.items() if c < 2}
|
||||
|
||||
for stock in stocks:
|
||||
scode = stock.get('scode', '')
|
||||
valid_sectors = [s for s in stock.get('core_sectors', []) if s not in small_sectors]
|
||||
|
||||
if valid_sectors:
|
||||
for sector in valid_sectors:
|
||||
sector_stocks[sector].append(scode)
|
||||
else:
|
||||
sector_stocks['其他'].append(scode)
|
||||
|
||||
sector_data = {
|
||||
sector: {'count': len(codes), 'stock_codes': codes}
|
||||
for sector, codes in sector_stocks.items()
|
||||
}
|
||||
|
||||
# 排序:公告优先,然后按数量降序,其他放最后
|
||||
sorted_items = []
|
||||
announcement = sector_data.pop('公告', None)
|
||||
other = sector_data.pop('其他', None)
|
||||
|
||||
normal_items = sorted(sector_data.items(), key=lambda x: -x[1]['count'])
|
||||
|
||||
if announcement:
|
||||
sorted_items.append(('公告', announcement))
|
||||
sorted_items.extend(normal_items)
|
||||
if other:
|
||||
sorted_items.append(('其他', other))
|
||||
|
||||
return dict(sorted_items)
|
||||
|
||||
|
||||
def calculate_sector_relations_top10(stocks):
|
||||
"""计算板块关联 TOP10"""
|
||||
relations = defaultdict(int)
|
||||
stock_sectors = defaultdict(set)
|
||||
|
||||
for stock in stocks:
|
||||
scode = stock['scode']
|
||||
for sector in stock.get('core_sectors', []):
|
||||
stock_sectors[scode].add(sector)
|
||||
|
||||
for scode, sectors in stock_sectors.items():
|
||||
sector_list = list(sectors)
|
||||
for i in range(len(sector_list)):
|
||||
for j in range(i + 1, len(sector_list)):
|
||||
pair = tuple(sorted([sector_list[i], sector_list[j]]))
|
||||
relations[pair] += 1
|
||||
|
||||
sorted_relations = sorted(relations.items(), key=lambda x: -x[1])[:10]
|
||||
|
||||
return {
|
||||
'labels': [f"{p[0]} - {p[1]}" for p, _ in sorted_relations],
|
||||
'counts': [c for _, c in sorted_relations]
|
||||
}
|
||||
|
||||
|
||||
def export_daily_analysis(date):
|
||||
"""导出单日分析数据"""
|
||||
logger.info(f"导出日期: {date}")
|
||||
|
||||
# 获取数据
|
||||
stats = get_daily_stats(date)
|
||||
stocks = get_daily_stocks(date)
|
||||
|
||||
if not stocks:
|
||||
logger.warning(f"日期 {date} 无数据")
|
||||
return None
|
||||
|
||||
# 处理板块数据
|
||||
sector_data = process_sector_data(stats.get('sector_stats', []), stocks)
|
||||
|
||||
# 计算板块关联
|
||||
sector_relations = calculate_sector_relations_top10(stocks)
|
||||
|
||||
# 生成图表数据
|
||||
chart_data = stats.get('chart_data', {
|
||||
'labels': [s for s in sector_data.keys() if s not in ['其他', '公告']],
|
||||
'counts': [d['count'] for s, d in sector_data.items() if s not in ['其他', '公告']]
|
||||
})
|
||||
|
||||
# 组装分析数据
|
||||
analysis = {
|
||||
'date': date,
|
||||
'formatted_date': f"{date[:4]}-{date[4:6]}-{date[6:]}",
|
||||
'total_stocks': len(stocks),
|
||||
'sector_data': sector_data,
|
||||
'chart_data': chart_data,
|
||||
'word_freq_data': stats.get('word_freq', []),
|
||||
'sector_relations_top10': sector_relations,
|
||||
'stocks': stocks # 包含完整股票列表
|
||||
}
|
||||
|
||||
# 保存文件
|
||||
output_path = os.path.join(OUTPUT_DIR, 'daily', f'{date}.json')
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(analysis, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"已保存: {output_path} ({len(stocks)} 只股票)")
|
||||
return analysis
|
||||
|
||||
|
||||
def export_dates_index(dates):
|
||||
"""导出日期索引"""
|
||||
output_path = os.path.join(OUTPUT_DIR, 'dates.json')
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
'dates': dates,
|
||||
'total': len(dates),
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"已保存日期索引: {output_path} ({len(dates)} 个日期)")
|
||||
|
||||
|
||||
def export_stocks_for_search(dates_to_export):
|
||||
"""导出股票数据用于搜索(JSONL 格式)"""
|
||||
output_path = os.path.join(OUTPUT_DIR, 'stocks.jsonl')
|
||||
|
||||
total_count = 0
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
for date_info in dates_to_export:
|
||||
date = date_info['date']
|
||||
stocks = get_daily_stocks(date)
|
||||
|
||||
for stock in stocks:
|
||||
# 只保留搜索需要的字段
|
||||
search_record = {
|
||||
'date': stock.get('date'),
|
||||
'scode': stock.get('scode'),
|
||||
'sname': stock.get('sname'),
|
||||
'brief': stock.get('brief', ''),
|
||||
'core_sectors': stock.get('core_sectors', []),
|
||||
'zt_time': stock.get('zt_time'),
|
||||
'formatted_time': stock.get('formatted_time', ''),
|
||||
'continuous_days': stock.get('continuous_days', '')
|
||||
}
|
||||
f.write(json.dumps(search_record, ensure_ascii=False) + '\n')
|
||||
total_count += 1
|
||||
|
||||
logger.info(f"已保存搜索数据: {output_path} ({total_count} 条记录)")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='导出涨停分析数据到 JSON 文件')
|
||||
parser.add_argument('--days', type=int, default=30, help='导出最近 N 天的数据')
|
||||
parser.add_argument('--date', type=str, help='导出指定日期 (YYYYMMDD)')
|
||||
parser.add_argument('--all', action='store_true', help='导出所有数据')
|
||||
parser.add_argument('--no-search', action='store_true', help='不导出搜索数据')
|
||||
args = parser.parse_args()
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
# 获取所有可用日期
|
||||
all_dates = get_available_dates()
|
||||
logger.info(f"ES 中共有 {len(all_dates)} 个日期的数据")
|
||||
|
||||
if not all_dates:
|
||||
logger.error("未找到任何数据")
|
||||
return
|
||||
|
||||
# 确定要导出的日期
|
||||
if args.date:
|
||||
dates_to_export = [d for d in all_dates if d['date'] == args.date]
|
||||
if not dates_to_export:
|
||||
logger.error(f"未找到日期 {args.date} 的数据")
|
||||
return
|
||||
elif args.all:
|
||||
dates_to_export = all_dates
|
||||
else:
|
||||
# 默认导出最近 N 天
|
||||
dates_to_export = all_dates[:args.days]
|
||||
|
||||
logger.info(f"将导出 {len(dates_to_export)} 个日期的数据")
|
||||
|
||||
# 导出每日分析数据
|
||||
for date_info in dates_to_export:
|
||||
try:
|
||||
export_daily_analysis(date_info['date'])
|
||||
except Exception as e:
|
||||
logger.error(f"导出 {date_info['date']} 失败: {e}")
|
||||
|
||||
# 导出日期索引(使用所有日期)
|
||||
export_dates_index(all_dates)
|
||||
|
||||
# 导出搜索数据
|
||||
if not args.no_search:
|
||||
export_stocks_for_search(dates_to_export)
|
||||
|
||||
logger.info("导出完成!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
155
gunicorn_app_config.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Gunicorn 配置文件 - app.py 生产环境配置(支持 Flask-SocketIO + WebSocket + 多进程)
|
||||
|
||||
使用方式:
|
||||
# 推荐方式: 使用此配置文件启动(多 Worker 模式)
|
||||
gunicorn -c gunicorn_app_config.py app:app
|
||||
|
||||
# 单 Worker 调试模式:
|
||||
gunicorn -c gunicorn_app_config.py -w 1 app:app
|
||||
|
||||
多进程架构说明:
|
||||
- Flask Session 使用 Redis 存储(db=1),所有 Worker 共享
|
||||
- SocketIO 使用 Redis 消息队列(db=2),跨 Worker 消息同步
|
||||
- 微信登录状态使用 Redis 存储(db=0),所有 Worker 共享
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# ==================== 基础配置 ====================
|
||||
|
||||
# 绑定地址和端口
|
||||
bind = '0.0.0.0:5001'
|
||||
|
||||
# Worker 进程数
|
||||
# 16 核心机器推荐: 8 workers × 4 threads = 32 并发处理能力
|
||||
# 公式: workers = min(CPU_CORES, 2 * CPU_CORES + 1) 取决于是否 I/O 密集
|
||||
workers = 8
|
||||
|
||||
# 每个 worker 的线程数
|
||||
threads = 4
|
||||
|
||||
# Worker 类型 - 使用 gthread(多线程,配合 simple-websocket 支持 WebSocket)
|
||||
# 参考: https://flask-socketio.readthedocs.io/en/latest/deployment.html
|
||||
# gthread 是最稳定的方案,适用于 Python 3.10+
|
||||
worker_class = 'gthread'
|
||||
|
||||
# Worker 连接数(gevent 异步模式下可以处理大量并发连接)
|
||||
worker_connections = 2000
|
||||
|
||||
# 每个 worker 处理的最大请求数(防止内存泄漏)
|
||||
# 对于 WebSocket 长连接,设置一个较大的值,不能是 0(否则内存泄漏无法恢复)
|
||||
max_requests = 10000 # 处理 10000 个请求后重启 worker
|
||||
max_requests_jitter = 1000 # 随机抖动,避免所有 worker 同时重启
|
||||
|
||||
# ==================== 超时配置 ====================
|
||||
|
||||
# Worker 超时时间(秒),WebSocket 需要长连接,设大一些
|
||||
timeout = 300
|
||||
|
||||
# 优雅关闭超时时间(秒)
|
||||
graceful_timeout = 30
|
||||
|
||||
# 保持连接超时时间(秒)
|
||||
keepalive = 65
|
||||
|
||||
# ==================== SSL 配置 ====================
|
||||
|
||||
cert_file = '/etc/letsencrypt/live/valuefrontier.cn/fullchain.pem'
|
||||
key_file = '/etc/letsencrypt/live/valuefrontier.cn/privkey.pem'
|
||||
|
||||
if os.path.exists(cert_file) and os.path.exists(key_file):
|
||||
certfile = cert_file
|
||||
keyfile = key_file
|
||||
|
||||
# ==================== 日志配置 ====================
|
||||
|
||||
accesslog = '-'
|
||||
errorlog = '-'
|
||||
loglevel = 'debug' # 调试时用 debug,正常运行用 info
|
||||
capture_output = True # 捕获 print 输出到日志
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)sμs'
|
||||
|
||||
# ==================== 进程管理 ====================
|
||||
|
||||
daemon = False
|
||||
pidfile = '/tmp/gunicorn_app.pid'
|
||||
proc_name = 'vf_react_app'
|
||||
|
||||
# 不预加载应用,确保 gevent monkey patch 正确
|
||||
preload_app = False
|
||||
|
||||
# ==================== Hook 函数 ====================
|
||||
|
||||
def on_starting(server):
|
||||
"""服务器启动时调用"""
|
||||
print("=" * 70)
|
||||
print("🚀 Gunicorn + Flask-SocketIO 多进程服务器正在启动...")
|
||||
print(f" Workers: {server.app.cfg.workers}")
|
||||
print(f" Worker Class: {server.app.cfg.worker_class}")
|
||||
print(f" Bind: {server.app.cfg.bind}")
|
||||
print(f" Worker Connections: {server.app.cfg.worker_connections}")
|
||||
print(f" Max Requests: {server.app.cfg.max_requests}")
|
||||
print("-" * 70)
|
||||
print(" Redis 存储分配:")
|
||||
print(" - db=0: 微信登录状态")
|
||||
print(" - db=1: Flask Session")
|
||||
print(" - db=2: SocketIO 消息队列")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def when_ready(server):
|
||||
"""服务准备就绪时调用"""
|
||||
print(f"✅ Gunicorn 服务准备就绪! {server.app.cfg.workers} 个 Worker 已启动")
|
||||
print(" 多进程支持已启用 (Redis Session + SocketIO Message Queue)")
|
||||
|
||||
|
||||
def post_worker_init(worker):
|
||||
"""Worker 初始化完成后调用"""
|
||||
# gevent monkey patching 在这里自动完成
|
||||
print(f"✅ Worker {worker.pid} 已初始化 (gevent 异步模式, 可处理 2000 并发连接)")
|
||||
|
||||
|
||||
def worker_abort(worker):
|
||||
"""Worker 收到 SIGABRT 信号时调用(超时)"""
|
||||
print(f"⚠️ Worker {worker.pid} 超时被终止,正在重启...")
|
||||
|
||||
|
||||
def on_exit(server):
|
||||
"""服务器退出时调用"""
|
||||
print("🛑 Gunicorn 服务器已关闭")
|
||||
|
||||
|
||||
# ==================== systemd 服务配置示例 ====================
|
||||
"""
|
||||
保存为 /etc/systemd/system/vf_react.service:
|
||||
|
||||
[Unit]
|
||||
Description=VF React Flask Application with SocketIO
|
||||
After=network.target redis.service mysql.service
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/path/to/vf_react
|
||||
Environment="PATH=/path/to/venv/bin"
|
||||
ExecStart=/path/to/venv/bin/gunicorn -c gunicorn_app_config.py app:app
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
启用服务:
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable vf_react
|
||||
sudo systemctl start vf_react
|
||||
sudo systemctl status vf_react
|
||||
|
||||
查看日志:
|
||||
sudo journalctl -u vf_react -f
|
||||
"""
|
||||
|
||||
@@ -45,8 +45,8 @@ keepalive = 5
|
||||
# ==================== SSL 配置 ====================
|
||||
|
||||
# SSL 证书路径(生产环境需要配置)
|
||||
cert_file = '/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem'
|
||||
key_file = '/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem'
|
||||
cert_file = '/etc/nginx/ssl/api.valuefrontier.cn/fullchain.pem'
|
||||
key_file = '/etc/nginx/ssl/api.valuefrontier.cn/privkey.pem'
|
||||
|
||||
if os.path.exists(cert_file) and os.path.exists(key_file):
|
||||
certfile = cert_file
|
||||
|
||||
221
gunicorn_eventlet_config.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Gunicorn 配置文件 - Eventlet 极限高并发配置(110.42.32.207 专用)
|
||||
|
||||
服务器配置: 48核心 128GB 内存
|
||||
目标并发: 160,000+ 并发连接
|
||||
|
||||
使用方式:
|
||||
# 设置环境变量后启动
|
||||
export REDIS_HOST=127.0.0.1
|
||||
gunicorn -c gunicorn_eventlet_config.py app:app
|
||||
|
||||
# 或者一行命令
|
||||
REDIS_HOST=127.0.0.1 gunicorn -c gunicorn_eventlet_config.py app:app
|
||||
|
||||
架构说明:
|
||||
- 16 个 Eventlet Worker(每个占用 1 核心,预留 32 核给系统/Redis/MySQL)
|
||||
- 每个 Worker 处理 10000+ 并发连接(协程异步 I/O)
|
||||
- Redis 消息队列同步跨 Worker 的 WebSocket 消息
|
||||
- 总并发能力: 16 × 10000 = 160,000+ 连接
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# ==================== 环境变量设置 ====================
|
||||
# 解决 eventlet greendns 无法解析 localhost 的问题
|
||||
os.environ.setdefault('REDIS_HOST', '127.0.0.1')
|
||||
|
||||
# ==================== 基础配置 ====================
|
||||
|
||||
# 绑定地址和端口
|
||||
bind = '0.0.0.0:5001'
|
||||
|
||||
# Worker 进程数
|
||||
# 48 核心机器: 16 Workers(预留资源给 Redis/MySQL/系统)
|
||||
# 每个 Eventlet Worker 是单线程但支持协程并发
|
||||
workers = 16
|
||||
|
||||
# Worker 类型 - eventlet 异步模式
|
||||
worker_class = 'eventlet'
|
||||
|
||||
# 每个 worker 的最大并发连接数
|
||||
# 16 Workers × 10000 = 160,000 并发连接能力
|
||||
worker_connections = 10000
|
||||
|
||||
# 每个 worker 处理的最大请求数(防止内存泄漏)
|
||||
# 128GB 内存充足,可以设置较大值
|
||||
max_requests = 100000
|
||||
max_requests_jitter = 10000
|
||||
|
||||
# ==================== 超时配置 ====================
|
||||
|
||||
# Worker 超时时间(秒)
|
||||
# WebSocket 长连接需要较长超时
|
||||
timeout = 600
|
||||
|
||||
# 优雅关闭超时时间(秒)
|
||||
# 16 个 Worker 需要更长的优雅关闭时间
|
||||
graceful_timeout = 120
|
||||
|
||||
# 保持连接超时时间(秒)
|
||||
keepalive = 120
|
||||
|
||||
# ==================== 内存优化 ====================
|
||||
|
||||
# 128GB 内存,可以适当增加缓冲区
|
||||
# 限制请求行大小(防止恶意请求)
|
||||
limit_request_line = 8190
|
||||
|
||||
# 限制请求头数量
|
||||
limit_request_fields = 200
|
||||
|
||||
# 限制请求头大小
|
||||
limit_request_field_size = 8190
|
||||
|
||||
# ==================== 日志配置 ====================
|
||||
|
||||
accesslog = '-'
|
||||
errorlog = '-'
|
||||
loglevel = 'info'
|
||||
capture_output = True
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)sμs'
|
||||
|
||||
# ==================== 进程管理 ====================
|
||||
|
||||
daemon = False
|
||||
pidfile = '/tmp/gunicorn_eventlet.pid'
|
||||
proc_name = 'vf_react_eventlet'
|
||||
|
||||
# 不预加载应用
|
||||
preload_app = False
|
||||
|
||||
# ==================== Hook 函数 ====================
|
||||
|
||||
def on_starting(server):
|
||||
"""服务器启动时调用"""
|
||||
workers = server.app.cfg.workers
|
||||
connections = server.app.cfg.worker_connections
|
||||
total = workers * connections
|
||||
|
||||
print("=" * 70)
|
||||
print("🚀 Gunicorn + Eventlet 极限高并发服务器正在启动...")
|
||||
print("=" * 70)
|
||||
print(f" 服务器配置: 48核心 128GB 内存")
|
||||
print(f" Workers: {workers} 个 Eventlet 协程进程")
|
||||
print(f" 每 Worker 连接数: {connections:,}")
|
||||
print(f" 总并发能力: {total:,} 连接")
|
||||
print("-" * 70)
|
||||
print(f" Bind: {server.app.cfg.bind}")
|
||||
print(f" Max Requests: {server.app.cfg.max_requests:,}")
|
||||
print(f" Timeout: {server.app.cfg.timeout}s")
|
||||
print("-" * 70)
|
||||
print(" 多进程架构:")
|
||||
print(" - Redis db=0: 微信登录状态(跨 Worker 共享)")
|
||||
print(" - Redis db=1: Flask Session(跨 Worker 共享)")
|
||||
print(" - Redis db=2: SocketIO 消息队列(跨 Worker 同步)")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def when_ready(server):
|
||||
"""服务准备就绪时调用"""
|
||||
workers = server.app.cfg.workers
|
||||
connections = server.app.cfg.worker_connections
|
||||
total = workers * connections
|
||||
|
||||
print("=" * 70)
|
||||
print(f"✅ Gunicorn + Eventlet 服务准备就绪!")
|
||||
print(f" {workers} 个 Worker 已启动")
|
||||
print(f" 总并发能力: {total:,} 连接")
|
||||
print(f" WebSocket + HTTP API 混合高并发已启用")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def post_worker_init(worker):
|
||||
"""Worker 初始化完成后调用"""
|
||||
print(f"✅ Eventlet Worker {worker.pid} 已初始化 (10,000 并发连接就绪)")
|
||||
|
||||
|
||||
def worker_abort(worker):
|
||||
"""Worker 收到 SIGABRT 信号时调用(超时)"""
|
||||
print(f"⚠️ Worker {worker.pid} 超时被终止,正在重启...")
|
||||
|
||||
|
||||
def on_exit(server):
|
||||
"""服务器退出时调用"""
|
||||
print("🛑 Gunicorn + Eventlet 服务器已关闭")
|
||||
|
||||
|
||||
# ==================== systemd 服务配置示例 ====================
|
||||
"""
|
||||
保存为 /etc/systemd/system/vf_eventlet.service:
|
||||
|
||||
[Unit]
|
||||
Description=VF React Flask (Eventlet 160K Concurrent) - 48 Core 128GB
|
||||
After=network.target redis.service mysql.service
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/path/to/vf_react
|
||||
Environment="PATH=/path/to/venv/bin"
|
||||
Environment="REDIS_HOST=127.0.0.1"
|
||||
|
||||
# 系统资源限制(48核128GB专用服务器)
|
||||
LimitNOFILE=200000
|
||||
LimitNPROC=65535
|
||||
|
||||
ExecStart=/path/to/venv/bin/gunicorn -c gunicorn_eventlet_config.py app:app
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
启用服务:
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable vf_eventlet
|
||||
sudo systemctl start vf_eventlet
|
||||
sudo systemctl status vf_eventlet
|
||||
|
||||
查看日志:
|
||||
sudo journalctl -u vf_eventlet -f
|
||||
|
||||
============================================================
|
||||
系统优化(在 110.42.32.207 上执行):
|
||||
|
||||
# 1. 增加文件描述符限制
|
||||
echo "* soft nofile 200000" >> /etc/security/limits.conf
|
||||
echo "* hard nofile 200000" >> /etc/security/limits.conf
|
||||
|
||||
# 2. 内核参数优化(/etc/sysctl.conf)
|
||||
cat >> /etc/sysctl.conf << 'EOF'
|
||||
# TCP 连接优化
|
||||
net.core.somaxconn = 65535
|
||||
net.core.netdev_max_backlog = 65535
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
|
||||
# 端口范围
|
||||
net.ipv4.ip_local_port_range = 1024 65535
|
||||
|
||||
# TIME_WAIT 优化
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
net.ipv4.tcp_fin_timeout = 15
|
||||
|
||||
# 内存优化(128GB 内存)
|
||||
net.core.rmem_max = 16777216
|
||||
net.core.wmem_max = 16777216
|
||||
net.ipv4.tcp_rmem = 4096 87380 16777216
|
||||
net.ipv4.tcp_wmem = 4096 65536 16777216
|
||||
|
||||
# 连接跟踪(防火墙)
|
||||
net.netfilter.nf_conntrack_max = 1000000
|
||||
EOF
|
||||
|
||||
# 3. 应用内核参数
|
||||
sysctl -p
|
||||
|
||||
============================================================
|
||||
"""
|
||||
1
gvQnxIQ5Rs.txt
Normal file
@@ -0,0 +1 @@
|
||||
d526e9e857dbd2621e5100811972e8c5
|
||||
@@ -61,6 +61,11 @@ HTTP_CLIENT = httpx.AsyncClient(timeout=60.0)
|
||||
|
||||
# 模型配置字典(支持动态切换)
|
||||
MODEL_CONFIGS = {
|
||||
"deepseek": {
|
||||
"api_key": "sk-7363bdb28d7d4bf0aa68eb9449f8f063",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"model": "deepseek-chat", # 默认模型
|
||||
},
|
||||
"kimi-k2": {
|
||||
"api_key": "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
@@ -88,8 +93,8 @@ MODEL_CONFIGS = {
|
||||
},
|
||||
}
|
||||
|
||||
# 保持向后兼容的配置(默认使用 kimi-k2-thinking)
|
||||
KIMI_CONFIG = MODEL_CONFIGS["kimi-k2-thinking"]
|
||||
# 保持向后兼容的配置(默认使用 deepseek)
|
||||
KIMI_CONFIG = MODEL_CONFIGS["deepseek"]
|
||||
DEEPMONEY_CONFIG = MODEL_CONFIGS["deepmoney"]
|
||||
|
||||
# ==================== MCP协议数据模型 ====================
|
||||
@@ -166,7 +171,7 @@ class AgentChatRequest(BaseModel):
|
||||
user_avatar: Optional[str] = None # 用户头像URL
|
||||
subscription_type: Optional[str] = None # 用户订阅类型(free/pro/max)
|
||||
session_id: Optional[str] = None # 会话ID(如果为空则创建新会话)
|
||||
model: Optional[str] = "kimi-k2-thinking" # 选择的模型(kimi-k2, kimi-k2-thinking, glm-4.6, deepmoney, gemini-3)
|
||||
model: Optional[str] = "deepseek" # 选择的模型(deepseek, kimi-k2, kimi-k2-thinking, glm-4.6, deepmoney, gemini-3)
|
||||
tools: Optional[List[str]] = None # 选择的工具列表(工具名称数组,如果为None则使用全部工具)
|
||||
|
||||
# ==================== MCP工具定义 ====================
|
||||
@@ -3100,8 +3105,8 @@ async def agent_chat_stream(chat_request: AgentChatRequest, request: Request):
|
||||
logger.info(f"[工具过滤] 使用全部 {len(tools)} 个工具")
|
||||
|
||||
# ==================== 动态模型选择 ====================
|
||||
selected_model = chat_request.model or "kimi-k2-thinking"
|
||||
model_config = MODEL_CONFIGS.get(selected_model, MODEL_CONFIGS["kimi-k2-thinking"])
|
||||
selected_model = chat_request.model or "deepseek"
|
||||
model_config = MODEL_CONFIGS.get(selected_model, MODEL_CONFIGS["deepseek"])
|
||||
logger.info(f"[模型选择] 使用模型: {selected_model} ({model_config['model']})")
|
||||
|
||||
# 返回流式响应
|
||||
|
||||
112
ml/README.md
@@ -1,112 +0,0 @@
|
||||
# 概念异动检测 ML 模块
|
||||
|
||||
基于 Transformer Autoencoder 的概念异动检测系统。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.8+
|
||||
- PyTorch 2.0+ (CUDA 12.x for 5090 GPU)
|
||||
- ClickHouse, MySQL, Elasticsearch
|
||||
|
||||
## 数据库配置
|
||||
|
||||
当前配置(`prepare_data.py`):
|
||||
- MySQL: `192.168.1.5:3306`
|
||||
- Elasticsearch: `127.0.0.1:9200`
|
||||
- ClickHouse: `127.0.0.1:9000`
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install -r ml/requirements.txt
|
||||
|
||||
# 2. 安装 PyTorch (5090 需要 CUDA 12.4)
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu124
|
||||
|
||||
# 3. 运行训练
|
||||
chmod +x ml/run_training.sh
|
||||
./ml/run_training.sh
|
||||
```
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `model.py` | Transformer Autoencoder 模型定义 |
|
||||
| `prepare_data.py` | 数据提取和特征计算 |
|
||||
| `train.py` | 模型训练脚本 |
|
||||
| `inference.py` | 推理服务 |
|
||||
| `enhanced_detector.py` | 增强版检测器(融合 Alpha + ML) |
|
||||
|
||||
## 训练参数
|
||||
|
||||
```bash
|
||||
# 完整参数
|
||||
./ml/run_training.sh --start 2022-01-01 --end 2024-12-01 --epochs 100 --batch_size 256
|
||||
|
||||
# 只准备数据
|
||||
python ml/prepare_data.py --start 2022-01-01
|
||||
|
||||
# 只训练(数据已准备好)
|
||||
python ml/train.py --epochs 100 --batch_size 256 --lr 1e-4
|
||||
```
|
||||
|
||||
## 模型架构
|
||||
|
||||
```
|
||||
输入: (batch, 30, 6) # 30分钟序列,6个特征
|
||||
↓
|
||||
Positional Encoding
|
||||
↓
|
||||
Transformer Encoder (4层, 8头, d=128)
|
||||
↓
|
||||
Bottleneck (压缩到 32 维)
|
||||
↓
|
||||
Transformer Decoder (4层)
|
||||
↓
|
||||
输出: (batch, 30, 6) # 重构序列
|
||||
|
||||
异动判断: reconstruction_error > threshold
|
||||
```
|
||||
|
||||
## 6维特征
|
||||
|
||||
1. `alpha` - 超额收益(概念涨幅 - 大盘涨幅)
|
||||
2. `alpha_delta` - Alpha 5分钟变化
|
||||
3. `amt_ratio` - 成交额 / 20分钟均值
|
||||
4. `amt_delta` - 成交额变化率
|
||||
5. `rank_pct` - Alpha 排名百分位
|
||||
6. `limit_up_ratio` - 涨停股占比
|
||||
|
||||
## 训练产出
|
||||
|
||||
训练完成后,`ml/checkpoints/` 包含:
|
||||
- `best_model.pt` - 最佳模型权重
|
||||
- `thresholds.json` - 异动阈值 (P90/P95/P99)
|
||||
- `normalization_stats.json` - 数据标准化参数
|
||||
- `config.json` - 训练配置
|
||||
|
||||
## 使用示例
|
||||
|
||||
```python
|
||||
from ml.inference import ConceptAnomalyDetector
|
||||
|
||||
detector = ConceptAnomalyDetector('ml/checkpoints')
|
||||
|
||||
# 实时检测
|
||||
is_anomaly, score = detector.detect(
|
||||
concept_name="人工智能",
|
||||
features={
|
||||
'alpha': 2.5,
|
||||
'alpha_delta': 0.8,
|
||||
'amt_ratio': 1.5,
|
||||
'amt_delta': 0.3,
|
||||
'rank_pct': 0.95,
|
||||
'limit_up_ratio': 0.15,
|
||||
}
|
||||
)
|
||||
|
||||
if is_anomaly:
|
||||
print(f"检测到异动!分数: {score}")
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
概念异动检测 ML 模块
|
||||
|
||||
提供基于 Transformer Autoencoder 的异动检测功能
|
||||
"""
|
||||
|
||||
from .inference import ConceptAnomalyDetector, MLAnomalyService
|
||||
|
||||
__all__ = ['ConceptAnomalyDetector', 'MLAnomalyService']
|
||||
481
ml/backtest.py
@@ -1,481 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
历史异动回测脚本
|
||||
|
||||
使用训练好的模型,对历史数据进行异动检测,生成异动记录
|
||||
|
||||
使用方法:
|
||||
# 回测指定日期范围
|
||||
python backtest.py --start 2024-01-01 --end 2024-12-01
|
||||
|
||||
# 回测单天
|
||||
python backtest.py --start 2024-11-01 --end 2024-11-01
|
||||
|
||||
# 只生成结果,不写入数据库
|
||||
python backtest.py --start 2024-01-01 --dry-run
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
from tqdm import tqdm
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# 添加父目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from model import TransformerAutoencoder
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
MYSQL_ENGINE = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
|
||||
echo=False
|
||||
)
|
||||
|
||||
# 特征列表(与训练一致)
|
||||
FEATURES = [
|
||||
'alpha',
|
||||
'alpha_delta',
|
||||
'amt_ratio',
|
||||
'amt_delta',
|
||||
'rank_pct',
|
||||
'limit_up_ratio',
|
||||
]
|
||||
|
||||
# 回测配置
|
||||
BACKTEST_CONFIG = {
|
||||
'seq_len': 30, # 序列长度
|
||||
'threshold_key': 'p95', # 使用的阈值
|
||||
'min_alpha_abs': 0.5, # 最小 Alpha 绝对值(过滤微小波动)
|
||||
'cooldown_minutes': 8, # 同一概念冷却时间
|
||||
'max_alerts_per_minute': 15, # 每分钟最多异动数
|
||||
'clip_value': 10.0, # 极端值截断
|
||||
}
|
||||
|
||||
|
||||
# ==================== 模型加载 ====================
|
||||
|
||||
class AnomalyDetector:
|
||||
"""异动检测器"""
|
||||
|
||||
def __init__(self, checkpoint_dir: str = 'ml/checkpoints', device: str = 'auto'):
|
||||
self.checkpoint_dir = Path(checkpoint_dir)
|
||||
|
||||
# 设备
|
||||
if device == 'auto':
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
else:
|
||||
self.device = torch.device(device)
|
||||
|
||||
# 加载配置
|
||||
self._load_config()
|
||||
|
||||
# 加载模型
|
||||
self._load_model()
|
||||
|
||||
# 加载阈值
|
||||
self._load_thresholds()
|
||||
|
||||
print(f"AnomalyDetector 初始化完成")
|
||||
print(f" 设备: {self.device}")
|
||||
print(f" 阈值 ({BACKTEST_CONFIG['threshold_key']}): {self.threshold:.6f}")
|
||||
|
||||
def _load_config(self):
|
||||
config_path = self.checkpoint_dir / 'config.json'
|
||||
with open(config_path, 'r') as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
def _load_model(self):
|
||||
model_path = self.checkpoint_dir / 'best_model.pt'
|
||||
checkpoint = torch.load(model_path, map_location=self.device)
|
||||
|
||||
model_config = self.config['model'].copy()
|
||||
model_config['use_instance_norm'] = self.config.get('use_instance_norm', True)
|
||||
|
||||
self.model = TransformerAutoencoder(**model_config)
|
||||
self.model.load_state_dict(checkpoint['model_state_dict'])
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
def _load_thresholds(self):
|
||||
thresholds_path = self.checkpoint_dir / 'thresholds.json'
|
||||
with open(thresholds_path, 'r') as f:
|
||||
thresholds = json.load(f)
|
||||
|
||||
self.threshold = thresholds[BACKTEST_CONFIG['threshold_key']]
|
||||
|
||||
@torch.no_grad()
|
||||
def compute_anomaly_scores(self, sequences: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
计算异动分数
|
||||
|
||||
Args:
|
||||
sequences: (n_sequences, seq_len, n_features)
|
||||
Returns:
|
||||
scores: (n_sequences,) 每个序列最后时刻的异动分数
|
||||
"""
|
||||
# 截断极端值
|
||||
sequences = np.clip(sequences, -BACKTEST_CONFIG['clip_value'], BACKTEST_CONFIG['clip_value'])
|
||||
|
||||
# 转为 tensor
|
||||
x = torch.FloatTensor(sequences).to(self.device)
|
||||
|
||||
# 计算重构误差
|
||||
errors = self.model.compute_reconstruction_error(x, reduction='none')
|
||||
|
||||
# 取最后一个时刻的误差
|
||||
scores = errors[:, -1].cpu().numpy()
|
||||
|
||||
return scores
|
||||
|
||||
def is_anomaly(self, score: float) -> bool:
|
||||
"""判断是否异动"""
|
||||
return score > self.threshold
|
||||
|
||||
|
||||
# ==================== 数据加载 ====================
|
||||
|
||||
def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]:
|
||||
"""加载单天的特征数据"""
|
||||
file_path = Path(data_dir) / f"features_{date}.parquet"
|
||||
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
df = pd.read_parquet(file_path)
|
||||
return df
|
||||
|
||||
|
||||
def get_available_dates(data_dir: str, start_date: str, end_date: str) -> List[str]:
|
||||
"""获取可用的日期列表"""
|
||||
data_path = Path(data_dir)
|
||||
all_files = sorted(data_path.glob("features_*.parquet"))
|
||||
|
||||
dates = []
|
||||
for f in all_files:
|
||||
date = f.stem.replace('features_', '')
|
||||
if start_date <= date <= end_date:
|
||||
dates.append(date)
|
||||
|
||||
return dates
|
||||
|
||||
|
||||
# ==================== 回测逻辑 ====================
|
||||
|
||||
def backtest_single_day(
|
||||
detector: AnomalyDetector,
|
||||
df: pd.DataFrame,
|
||||
date: str,
|
||||
seq_len: int = 30
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
回测单天数据
|
||||
|
||||
Args:
|
||||
detector: 异动检测器
|
||||
df: 当天的特征数据
|
||||
date: 日期
|
||||
seq_len: 序列长度
|
||||
|
||||
Returns:
|
||||
alerts: 异动列表
|
||||
"""
|
||||
alerts = []
|
||||
|
||||
# 按概念分组
|
||||
grouped = df.groupby('concept_id', sort=False)
|
||||
|
||||
# 冷却记录 {concept_id: last_alert_timestamp}
|
||||
cooldown = {}
|
||||
|
||||
# 获取所有时间点
|
||||
all_timestamps = sorted(df['timestamp'].unique())
|
||||
|
||||
if len(all_timestamps) < seq_len:
|
||||
return alerts
|
||||
|
||||
# 对每个时间点进行检测(从第 seq_len 个开始)
|
||||
for t_idx in range(seq_len - 1, len(all_timestamps)):
|
||||
current_time = all_timestamps[t_idx]
|
||||
window_start_time = all_timestamps[t_idx - seq_len + 1]
|
||||
|
||||
minute_alerts = []
|
||||
|
||||
# 收集该时刻所有概念的序列
|
||||
concept_sequences = []
|
||||
concept_infos = []
|
||||
|
||||
for concept_id, concept_df in grouped:
|
||||
# 获取该概念在时间窗口内的数据
|
||||
mask = (concept_df['timestamp'] >= window_start_time) & (concept_df['timestamp'] <= current_time)
|
||||
window_df = concept_df[mask].sort_values('timestamp')
|
||||
|
||||
if len(window_df) < seq_len:
|
||||
continue
|
||||
|
||||
# 取最后 seq_len 个点
|
||||
window_df = window_df.tail(seq_len)
|
||||
|
||||
# 提取特征
|
||||
features = window_df[FEATURES].values
|
||||
|
||||
# 处理缺失值
|
||||
features = np.nan_to_num(features, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
# 获取当前时刻的信息
|
||||
current_row = window_df.iloc[-1]
|
||||
|
||||
concept_sequences.append(features)
|
||||
concept_infos.append({
|
||||
'concept_id': concept_id,
|
||||
'timestamp': current_time,
|
||||
'alpha': current_row.get('alpha', 0),
|
||||
'alpha_delta': current_row.get('alpha_delta', 0),
|
||||
'amt_ratio': current_row.get('amt_ratio', 1),
|
||||
'limit_up_ratio': current_row.get('limit_up_ratio', 0),
|
||||
'limit_down_ratio': current_row.get('limit_down_ratio', 0),
|
||||
'rank_pct': current_row.get('rank_pct', 0.5),
|
||||
'stock_count': current_row.get('stock_count', 0),
|
||||
'total_amt': current_row.get('total_amt', 0),
|
||||
})
|
||||
|
||||
if not concept_sequences:
|
||||
continue
|
||||
|
||||
# 批量计算异动分数
|
||||
sequences_array = np.array(concept_sequences)
|
||||
scores = detector.compute_anomaly_scores(sequences_array)
|
||||
|
||||
# 检测异动
|
||||
for i, (info, score) in enumerate(zip(concept_infos, scores)):
|
||||
concept_id = info['concept_id']
|
||||
alpha = info['alpha']
|
||||
|
||||
# 过滤小波动
|
||||
if abs(alpha) < BACKTEST_CONFIG['min_alpha_abs']:
|
||||
continue
|
||||
|
||||
# 检查冷却
|
||||
if concept_id in cooldown:
|
||||
last_alert = cooldown[concept_id]
|
||||
if isinstance(current_time, datetime):
|
||||
time_diff = (current_time - last_alert).total_seconds() / 60
|
||||
else:
|
||||
# timestamp 是字符串或其他格式
|
||||
time_diff = BACKTEST_CONFIG['cooldown_minutes'] + 1 # 跳过冷却检查
|
||||
|
||||
if time_diff < BACKTEST_CONFIG['cooldown_minutes']:
|
||||
continue
|
||||
|
||||
# 判断是否异动
|
||||
if not detector.is_anomaly(score):
|
||||
continue
|
||||
|
||||
# 记录异动
|
||||
alert_type = 'surge_up' if alpha > 0 else 'surge_down'
|
||||
|
||||
alert = {
|
||||
'concept_id': concept_id,
|
||||
'alert_time': current_time,
|
||||
'trade_date': date,
|
||||
'alert_type': alert_type,
|
||||
'anomaly_score': float(score),
|
||||
'threshold': detector.threshold,
|
||||
**info
|
||||
}
|
||||
|
||||
minute_alerts.append(alert)
|
||||
cooldown[concept_id] = current_time
|
||||
|
||||
# 按分数排序,限制数量
|
||||
minute_alerts.sort(key=lambda x: x['anomaly_score'], reverse=True)
|
||||
alerts.extend(minute_alerts[:BACKTEST_CONFIG['max_alerts_per_minute']])
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ==================== 数据库写入 ====================
|
||||
|
||||
def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int:
|
||||
"""保存异动到 MySQL"""
|
||||
if not alerts:
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
print(f" [Dry Run] 将写入 {len(alerts)} 条异动")
|
||||
return len(alerts)
|
||||
|
||||
saved = 0
|
||||
with MYSQL_ENGINE.begin() as conn:
|
||||
for alert in alerts:
|
||||
try:
|
||||
# 检查是否已存在
|
||||
check_sql = text("""
|
||||
SELECT id FROM concept_minute_alert
|
||||
WHERE concept_id = :concept_id
|
||||
AND alert_time = :alert_time
|
||||
AND trade_date = :trade_date
|
||||
""")
|
||||
exists = conn.execute(check_sql, {
|
||||
'concept_id': alert['concept_id'],
|
||||
'alert_time': alert['alert_time'],
|
||||
'trade_date': alert['trade_date'],
|
||||
}).fetchone()
|
||||
|
||||
if exists:
|
||||
continue
|
||||
|
||||
# 插入新记录
|
||||
insert_sql = text("""
|
||||
INSERT INTO concept_minute_alert
|
||||
(concept_id, concept_name, alert_time, alert_type, trade_date,
|
||||
change_pct, zscore, importance_score, stock_count, extra_info)
|
||||
VALUES
|
||||
(:concept_id, :concept_name, :alert_time, :alert_type, :trade_date,
|
||||
:change_pct, :zscore, :importance_score, :stock_count, :extra_info)
|
||||
""")
|
||||
|
||||
conn.execute(insert_sql, {
|
||||
'concept_id': alert['concept_id'],
|
||||
'concept_name': alert.get('concept_name', ''),
|
||||
'alert_time': alert['alert_time'],
|
||||
'alert_type': alert['alert_type'],
|
||||
'trade_date': alert['trade_date'],
|
||||
'change_pct': alert.get('alpha', 0),
|
||||
'zscore': alert['anomaly_score'],
|
||||
'importance_score': alert['anomaly_score'],
|
||||
'stock_count': alert.get('stock_count', 0),
|
||||
'extra_info': json.dumps({
|
||||
'detection_method': 'ml_autoencoder',
|
||||
'threshold': alert['threshold'],
|
||||
'alpha': alert.get('alpha', 0),
|
||||
'amt_ratio': alert.get('amt_ratio', 1),
|
||||
}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
saved += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" 保存失败: {alert['concept_id']} - {e}")
|
||||
|
||||
return saved
|
||||
|
||||
|
||||
def export_alerts_to_csv(alerts: List[Dict], output_path: str):
|
||||
"""导出异动到 CSV"""
|
||||
if not alerts:
|
||||
return
|
||||
|
||||
df = pd.DataFrame(alerts)
|
||||
df.to_csv(output_path, index=False, encoding='utf-8-sig')
|
||||
print(f"已导出到: {output_path}")
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='历史异动回测')
|
||||
parser.add_argument('--data_dir', type=str, default='ml/data',
|
||||
help='特征数据目录')
|
||||
parser.add_argument('--checkpoint_dir', type=str, default='ml/checkpoints',
|
||||
help='模型检查点目录')
|
||||
parser.add_argument('--start', type=str, required=True,
|
||||
help='开始日期 (YYYY-MM-DD)')
|
||||
parser.add_argument('--end', type=str, required=True,
|
||||
help='结束日期 (YYYY-MM-DD)')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='只计算,不写入数据库')
|
||||
parser.add_argument('--export-csv', type=str, default=None,
|
||||
help='导出 CSV 文件路径')
|
||||
parser.add_argument('--device', type=str, default='auto',
|
||||
help='设备 (auto/cuda/cpu)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("历史异动回测")
|
||||
print("=" * 60)
|
||||
print(f"日期范围: {args.start} ~ {args.end}")
|
||||
print(f"数据目录: {args.data_dir}")
|
||||
print(f"模型目录: {args.checkpoint_dir}")
|
||||
print(f"Dry Run: {args.dry_run}")
|
||||
print("=" * 60)
|
||||
|
||||
# 初始化检测器
|
||||
detector = AnomalyDetector(args.checkpoint_dir, args.device)
|
||||
|
||||
# 获取可用日期
|
||||
dates = get_available_dates(args.data_dir, args.start, args.end)
|
||||
|
||||
if not dates:
|
||||
print(f"未找到 {args.start} ~ {args.end} 范围内的数据")
|
||||
return
|
||||
|
||||
print(f"\n找到 {len(dates)} 天的数据")
|
||||
|
||||
# 回测
|
||||
all_alerts = []
|
||||
total_saved = 0
|
||||
|
||||
for date in tqdm(dates, desc="回测进度"):
|
||||
# 加载数据
|
||||
df = load_daily_features(args.data_dir, date)
|
||||
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
|
||||
# 回测单天
|
||||
alerts = backtest_single_day(
|
||||
detector, df, date,
|
||||
seq_len=BACKTEST_CONFIG['seq_len']
|
||||
)
|
||||
|
||||
if alerts:
|
||||
all_alerts.extend(alerts)
|
||||
|
||||
# 写入数据库
|
||||
saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run)
|
||||
total_saved += saved
|
||||
|
||||
if not args.dry_run:
|
||||
tqdm.write(f" {date}: 检测到 {len(alerts)} 个异动,保存 {saved} 条")
|
||||
|
||||
# 导出 CSV
|
||||
if args.export_csv and all_alerts:
|
||||
export_alerts_to_csv(all_alerts, args.export_csv)
|
||||
|
||||
# 汇总
|
||||
print("\n" + "=" * 60)
|
||||
print("回测完成!")
|
||||
print("=" * 60)
|
||||
print(f"总计检测到: {len(all_alerts)} 个异动")
|
||||
print(f"保存到数据库: {total_saved} 条")
|
||||
|
||||
# 统计
|
||||
if all_alerts:
|
||||
df_alerts = pd.DataFrame(all_alerts)
|
||||
print(f"\n异动类型分布:")
|
||||
print(df_alerts['alert_type'].value_counts())
|
||||
|
||||
print(f"\n异动分数统计:")
|
||||
print(f" Mean: {df_alerts['anomaly_score'].mean():.4f}")
|
||||
print(f" Max: {df_alerts['anomaly_score'].max():.4f}")
|
||||
print(f" Min: {df_alerts['anomaly_score'].min():.4f}")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,859 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
快速融合异动回测脚本
|
||||
|
||||
优化策略:
|
||||
1. 预先构建所有序列(向量化),避免循环内重复切片
|
||||
2. 批量 ML 推理(一次推理所有候选)
|
||||
3. 使用 NumPy 向量化操作替代 Python 循环
|
||||
|
||||
性能对比:
|
||||
- 原版:5分钟/天
|
||||
- 优化版:预计 10-30秒/天
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
from tqdm import tqdm
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
MYSQL_ENGINE = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
|
||||
echo=False
|
||||
)
|
||||
|
||||
FEATURES = ['alpha', 'alpha_delta', 'amt_ratio', 'amt_delta', 'rank_pct', 'limit_up_ratio']
|
||||
|
||||
CONFIG = {
|
||||
'seq_len': 15, # 序列长度(支持跨日后可从 9:30 检测)
|
||||
'min_alpha_abs': 0.3, # 最小 alpha 过滤
|
||||
'cooldown_minutes': 8,
|
||||
'max_alerts_per_minute': 20,
|
||||
'clip_value': 10.0,
|
||||
# === 融合权重:均衡 ===
|
||||
'rule_weight': 0.5,
|
||||
'ml_weight': 0.5,
|
||||
# === 触发阈值 ===
|
||||
'rule_trigger': 65, # 60 -> 65,略提高规则门槛
|
||||
'ml_trigger': 70, # 75 -> 70,略降低 ML 门槛
|
||||
'fusion_trigger': 45,
|
||||
}
|
||||
|
||||
|
||||
# ==================== 规则评分(向量化版)====================
|
||||
|
||||
def get_size_adjusted_thresholds(stock_count: np.ndarray) -> dict:
|
||||
"""
|
||||
根据概念股票数量计算动态阈值
|
||||
|
||||
设计思路:
|
||||
- 小概念(<10 只):波动大是正常的,需要更高阈值
|
||||
- 中概念(10-50 只):标准阈值
|
||||
- 大概念(>50 只):能有明显波动说明是真异动,降低阈值
|
||||
|
||||
返回各指标的调整系数(乘以基准阈值)
|
||||
"""
|
||||
n = len(stock_count)
|
||||
|
||||
# 基于股票数量的调整系数
|
||||
# 小概念:系数 > 1(提高阈值,更难触发)
|
||||
# 大概念:系数 < 1(降低阈值,更容易触发)
|
||||
size_factor = np.ones(n)
|
||||
|
||||
# 微型概念(<5 只):阈值 × 1.8
|
||||
tiny = stock_count < 5
|
||||
size_factor[tiny] = 1.8
|
||||
|
||||
# 小概念(5-10 只):阈值 × 1.4
|
||||
small = (stock_count >= 5) & (stock_count < 10)
|
||||
size_factor[small] = 1.4
|
||||
|
||||
# 中小概念(10-20 只):阈值 × 1.2
|
||||
medium_small = (stock_count >= 10) & (stock_count < 20)
|
||||
size_factor[medium_small] = 1.2
|
||||
|
||||
# 中概念(20-50 只):标准阈值 × 1.0
|
||||
medium = (stock_count >= 20) & (stock_count < 50)
|
||||
size_factor[medium] = 1.0
|
||||
|
||||
# 大概念(50-100 只):阈值 × 0.85
|
||||
large = (stock_count >= 50) & (stock_count < 100)
|
||||
size_factor[large] = 0.85
|
||||
|
||||
# 超大概念(>100 只):阈值 × 0.7
|
||||
xlarge = stock_count >= 100
|
||||
size_factor[xlarge] = 0.7
|
||||
|
||||
return size_factor
|
||||
|
||||
|
||||
def score_rules_batch(df: pd.DataFrame) -> Tuple[np.ndarray, List[List[str]]]:
|
||||
"""
|
||||
批量计算规则得分(向量化)- 考虑概念规模版
|
||||
|
||||
设计原则:
|
||||
- 规则作为辅助信号,不应单独主导决策
|
||||
- 根据概念股票数量动态调整阈值
|
||||
- 大概念异动更有价值,小概念需要更大波动才算异动
|
||||
|
||||
Args:
|
||||
df: DataFrame,包含所有特征列(必须包含 stock_count)
|
||||
Returns:
|
||||
scores: (n,) 规则得分数组
|
||||
triggered_rules: 每行触发的规则列表
|
||||
"""
|
||||
n = len(df)
|
||||
scores = np.zeros(n)
|
||||
triggered = [[] for _ in range(n)]
|
||||
|
||||
alpha = df['alpha'].values
|
||||
alpha_delta = df['alpha_delta'].values
|
||||
amt_ratio = df['amt_ratio'].values
|
||||
amt_delta = df['amt_delta'].values
|
||||
rank_pct = df['rank_pct'].values
|
||||
limit_up_ratio = df['limit_up_ratio'].values
|
||||
stock_count = df['stock_count'].values if 'stock_count' in df.columns else np.full(n, 20)
|
||||
|
||||
alpha_abs = np.abs(alpha)
|
||||
alpha_delta_abs = np.abs(alpha_delta)
|
||||
|
||||
# 获取基于规模的调整系数
|
||||
size_factor = get_size_adjusted_thresholds(stock_count)
|
||||
|
||||
# ========== Alpha 规则(动态阈值)==========
|
||||
# 基准阈值:极强 5%,强 4%,中等 3%
|
||||
# 实际阈值 = 基准 × size_factor
|
||||
|
||||
# 极强信号
|
||||
alpha_extreme_thresh = 5.0 * size_factor
|
||||
mask = alpha_abs >= alpha_extreme_thresh
|
||||
scores[mask] += 20
|
||||
for i in np.where(mask)[0]: triggered[i].append('alpha_extreme')
|
||||
|
||||
# 强信号
|
||||
alpha_strong_thresh = 4.0 * size_factor
|
||||
mask = (alpha_abs >= alpha_strong_thresh) & (alpha_abs < alpha_extreme_thresh)
|
||||
scores[mask] += 15
|
||||
for i in np.where(mask)[0]: triggered[i].append('alpha_strong')
|
||||
|
||||
# 中等信号
|
||||
alpha_medium_thresh = 3.0 * size_factor
|
||||
mask = (alpha_abs >= alpha_medium_thresh) & (alpha_abs < alpha_strong_thresh)
|
||||
scores[mask] += 10
|
||||
for i in np.where(mask)[0]: triggered[i].append('alpha_medium')
|
||||
|
||||
# ========== Alpha 加速度规则(动态阈值)==========
|
||||
delta_strong_thresh = 2.0 * size_factor
|
||||
mask = alpha_delta_abs >= delta_strong_thresh
|
||||
scores[mask] += 15
|
||||
for i in np.where(mask)[0]: triggered[i].append('alpha_delta_strong')
|
||||
|
||||
delta_medium_thresh = 1.5 * size_factor
|
||||
mask = (alpha_delta_abs >= delta_medium_thresh) & (alpha_delta_abs < delta_strong_thresh)
|
||||
scores[mask] += 10
|
||||
for i in np.where(mask)[0]: triggered[i].append('alpha_delta_medium')
|
||||
|
||||
# ========== 成交额规则(不受规模影响,放量就是放量)==========
|
||||
mask = amt_ratio >= 10.0
|
||||
scores[mask] += 20
|
||||
for i in np.where(mask)[0]: triggered[i].append('volume_extreme')
|
||||
|
||||
mask = (amt_ratio >= 6.0) & (amt_ratio < 10.0)
|
||||
scores[mask] += 12
|
||||
for i in np.where(mask)[0]: triggered[i].append('volume_strong')
|
||||
|
||||
# ========== 排名规则 ==========
|
||||
mask = rank_pct >= 0.98
|
||||
scores[mask] += 15
|
||||
for i in np.where(mask)[0]: triggered[i].append('rank_top')
|
||||
|
||||
mask = rank_pct <= 0.02
|
||||
scores[mask] += 15
|
||||
for i in np.where(mask)[0]: triggered[i].append('rank_bottom')
|
||||
|
||||
# ========== 涨停规则(动态阈值)==========
|
||||
# 大概念有涨停更有意义
|
||||
limit_high_thresh = 0.30 * size_factor
|
||||
mask = limit_up_ratio >= limit_high_thresh
|
||||
scores[mask] += 20
|
||||
for i in np.where(mask)[0]: triggered[i].append('limit_up_high')
|
||||
|
||||
limit_medium_thresh = 0.20 * size_factor
|
||||
mask = (limit_up_ratio >= limit_medium_thresh) & (limit_up_ratio < limit_high_thresh)
|
||||
scores[mask] += 12
|
||||
for i in np.where(mask)[0]: triggered[i].append('limit_up_medium')
|
||||
|
||||
# ========== 概念规模加分(大概念异动更有价值)==========
|
||||
# 大概念(50+)额外加分
|
||||
large_concept = stock_count >= 50
|
||||
has_signal = scores > 0 # 至少触发了某个规则
|
||||
mask = large_concept & has_signal
|
||||
scores[mask] += 10
|
||||
for i in np.where(mask)[0]: triggered[i].append('large_concept_bonus')
|
||||
|
||||
# 超大概念(100+)再加分
|
||||
xlarge_concept = stock_count >= 100
|
||||
mask = xlarge_concept & has_signal
|
||||
scores[mask] += 10
|
||||
for i in np.where(mask)[0]: triggered[i].append('xlarge_concept_bonus')
|
||||
|
||||
# ========== 组合规则(动态阈值)==========
|
||||
combo_alpha_thresh = 3.0 * size_factor
|
||||
|
||||
# Alpha + 放量 + 排名(三重验证)
|
||||
mask = (alpha_abs >= combo_alpha_thresh) & (amt_ratio >= 5.0) & ((rank_pct >= 0.95) | (rank_pct <= 0.05))
|
||||
scores[mask] += 20
|
||||
for i in np.where(mask)[0]: triggered[i].append('triple_signal')
|
||||
|
||||
# Alpha + 涨停(强组合)
|
||||
mask = (alpha_abs >= combo_alpha_thresh) & (limit_up_ratio >= 0.15 * size_factor)
|
||||
scores[mask] += 15
|
||||
for i in np.where(mask)[0]: triggered[i].append('alpha_with_limit')
|
||||
|
||||
# ========== 小概念惩罚(过滤噪音)==========
|
||||
# 微型概念(<5 只)如果只有单一信号,减分
|
||||
tiny_concept = stock_count < 5
|
||||
single_rule = np.array([len(t) <= 1 for t in triggered])
|
||||
mask = tiny_concept & single_rule & (scores > 0)
|
||||
scores[mask] *= 0.5 # 减半
|
||||
for i in np.where(mask)[0]: triggered[i].append('tiny_concept_penalty')
|
||||
|
||||
scores = np.clip(scores, 0, 100)
|
||||
return scores, triggered
|
||||
|
||||
|
||||
# ==================== ML 评分器 ====================
|
||||
|
||||
class FastMLScorer:
|
||||
"""快速 ML 评分器"""
|
||||
|
||||
def __init__(self, checkpoint_dir: str = 'ml/checkpoints', device: str = 'auto'):
|
||||
self.checkpoint_dir = Path(checkpoint_dir)
|
||||
|
||||
if device == 'auto':
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
elif device == 'cuda' and not torch.cuda.is_available():
|
||||
print("警告: CUDA 不可用,使用 CPU")
|
||||
self.device = torch.device('cpu')
|
||||
else:
|
||||
self.device = torch.device(device)
|
||||
|
||||
self.model = None
|
||||
self.thresholds = None
|
||||
self._load_model()
|
||||
|
||||
def _load_model(self):
|
||||
model_path = self.checkpoint_dir / 'best_model.pt'
|
||||
thresholds_path = self.checkpoint_dir / 'thresholds.json'
|
||||
config_path = self.checkpoint_dir / 'config.json'
|
||||
|
||||
if not model_path.exists():
|
||||
print(f"警告: 模型不存在 {model_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
from model import LSTMAutoencoder
|
||||
|
||||
config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
config = json.load(f).get('model', {})
|
||||
|
||||
# 处理旧配置键名
|
||||
if 'd_model' in config:
|
||||
config['hidden_dim'] = config.pop('d_model') // 2
|
||||
for key in ['num_encoder_layers', 'num_decoder_layers', 'nhead', 'dim_feedforward', 'max_seq_len', 'use_instance_norm']:
|
||||
config.pop(key, None)
|
||||
if 'num_layers' not in config:
|
||||
config['num_layers'] = 1
|
||||
|
||||
checkpoint = torch.load(model_path, map_location='cpu')
|
||||
self.model = LSTMAutoencoder(**config)
|
||||
self.model.load_state_dict(checkpoint['model_state_dict'])
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
if thresholds_path.exists():
|
||||
with open(thresholds_path) as f:
|
||||
self.thresholds = json.load(f)
|
||||
|
||||
print(f"ML模型加载成功 (设备: {self.device})")
|
||||
except Exception as e:
|
||||
print(f"ML模型加载失败: {e}")
|
||||
self.model = None
|
||||
|
||||
def is_ready(self):
|
||||
return self.model is not None
|
||||
|
||||
@torch.no_grad()
|
||||
def score_batch(self, sequences: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
批量计算 ML 得分
|
||||
|
||||
Args:
|
||||
sequences: (batch, seq_len, n_features)
|
||||
Returns:
|
||||
scores: (batch,) 0-100 分数
|
||||
"""
|
||||
if not self.is_ready() or len(sequences) == 0:
|
||||
return np.zeros(len(sequences))
|
||||
|
||||
x = torch.FloatTensor(sequences).to(self.device)
|
||||
output, _ = self.model(x)
|
||||
mse = ((output - x) ** 2).mean(dim=-1)
|
||||
errors = mse[:, -1].cpu().numpy()
|
||||
|
||||
p95 = self.thresholds.get('p95', 0.1) if self.thresholds else 0.1
|
||||
scores = np.clip(errors / p95 * 50, 0, 100)
|
||||
return scores
|
||||
|
||||
|
||||
# ==================== 快速回测 ====================
|
||||
|
||||
def build_sequences_fast(
|
||||
df: pd.DataFrame,
|
||||
seq_len: int = 30,
|
||||
prev_df: pd.DataFrame = None
|
||||
) -> Tuple[np.ndarray, pd.DataFrame]:
|
||||
"""
|
||||
快速构建所有有效序列
|
||||
|
||||
支持跨日序列:用前一天收盘数据 + 当天开盘数据拼接,实现 9:30 就能检测
|
||||
|
||||
Args:
|
||||
df: 当天数据
|
||||
seq_len: 序列长度
|
||||
prev_df: 前一天数据(可选,用于构建开盘时的序列)
|
||||
|
||||
返回:
|
||||
sequences: (n_valid, seq_len, n_features) 所有有效序列
|
||||
info_df: 对应的元信息 DataFrame
|
||||
"""
|
||||
# 确保按概念和时间排序
|
||||
df = df.sort_values(['concept_id', 'timestamp']).reset_index(drop=True)
|
||||
|
||||
# 如果有前一天数据,按概念构建尾部缓存(取每个概念最后 seq_len-1 条)
|
||||
prev_cache = {}
|
||||
if prev_df is not None and len(prev_df) > 0:
|
||||
prev_df = prev_df.sort_values(['concept_id', 'timestamp'])
|
||||
for concept_id, gdf in prev_df.groupby('concept_id'):
|
||||
tail_data = gdf.tail(seq_len - 1)
|
||||
if len(tail_data) > 0:
|
||||
feat_matrix = tail_data[FEATURES].values
|
||||
feat_matrix = np.nan_to_num(feat_matrix, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
feat_matrix = np.clip(feat_matrix, -CONFIG['clip_value'], CONFIG['clip_value'])
|
||||
prev_cache[concept_id] = feat_matrix
|
||||
|
||||
# 按概念分组
|
||||
groups = df.groupby('concept_id')
|
||||
|
||||
sequences = []
|
||||
infos = []
|
||||
|
||||
for concept_id, gdf in groups:
|
||||
gdf = gdf.reset_index(drop=True)
|
||||
|
||||
# 获取特征矩阵
|
||||
feat_matrix = gdf[FEATURES].values
|
||||
feat_matrix = np.nan_to_num(feat_matrix, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
feat_matrix = np.clip(feat_matrix, -CONFIG['clip_value'], CONFIG['clip_value'])
|
||||
|
||||
# 如果有前一天缓存,拼接到当天数据前面
|
||||
if concept_id in prev_cache:
|
||||
prev_data = prev_cache[concept_id]
|
||||
combined_matrix = np.vstack([prev_data, feat_matrix])
|
||||
# 计算偏移量:前一天数据的长度
|
||||
offset = len(prev_data)
|
||||
else:
|
||||
combined_matrix = feat_matrix
|
||||
offset = 0
|
||||
|
||||
# 滑动窗口构建序列
|
||||
n_total = len(combined_matrix)
|
||||
if n_total < seq_len:
|
||||
continue
|
||||
|
||||
for i in range(n_total - seq_len + 1):
|
||||
seq = combined_matrix[i:i + seq_len]
|
||||
|
||||
# 计算对应当天数据的索引
|
||||
# 序列最后一个点的位置 = i + seq_len - 1
|
||||
# 对应当天数据的索引 = (i + seq_len - 1) - offset
|
||||
today_idx = i + seq_len - 1 - offset
|
||||
|
||||
# 只要序列的最后一个点是当天的数据,就记录
|
||||
if today_idx < 0 or today_idx >= len(gdf):
|
||||
continue
|
||||
|
||||
sequences.append(seq)
|
||||
|
||||
# 记录最后一个时间步的信息(当天的)
|
||||
row = gdf.iloc[today_idx]
|
||||
infos.append({
|
||||
'concept_id': concept_id,
|
||||
'timestamp': row['timestamp'],
|
||||
'alpha': row['alpha'],
|
||||
'alpha_delta': row.get('alpha_delta', 0),
|
||||
'amt_ratio': row.get('amt_ratio', 1),
|
||||
'amt_delta': row.get('amt_delta', 0),
|
||||
'rank_pct': row.get('rank_pct', 0.5),
|
||||
'limit_up_ratio': row.get('limit_up_ratio', 0),
|
||||
'stock_count': row.get('stock_count', 0),
|
||||
'total_amt': row.get('total_amt', 0),
|
||||
})
|
||||
|
||||
if not sequences:
|
||||
return np.array([]), pd.DataFrame()
|
||||
|
||||
return np.array(sequences), pd.DataFrame(infos)
|
||||
|
||||
|
||||
def backtest_single_day_fast(
|
||||
ml_scorer: FastMLScorer,
|
||||
df: pd.DataFrame,
|
||||
date: str,
|
||||
config: Dict,
|
||||
prev_df: pd.DataFrame = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
快速回测单天(向量化版本)
|
||||
|
||||
Args:
|
||||
ml_scorer: ML 评分器
|
||||
df: 当天数据
|
||||
date: 日期
|
||||
config: 配置
|
||||
prev_df: 前一天数据(用于 9:30 开始检测)
|
||||
"""
|
||||
seq_len = config.get('seq_len', 30)
|
||||
|
||||
# 1. 构建所有序列(支持跨日)
|
||||
sequences, info_df = build_sequences_fast(df, seq_len, prev_df)
|
||||
|
||||
if len(sequences) == 0:
|
||||
return []
|
||||
|
||||
# 2. 过滤小波动
|
||||
alpha_abs = np.abs(info_df['alpha'].values)
|
||||
valid_mask = alpha_abs >= config['min_alpha_abs']
|
||||
|
||||
sequences = sequences[valid_mask]
|
||||
info_df = info_df[valid_mask].reset_index(drop=True)
|
||||
|
||||
if len(sequences) == 0:
|
||||
return []
|
||||
|
||||
# 3. 批量规则评分
|
||||
rule_scores, triggered_rules = score_rules_batch(info_df)
|
||||
|
||||
# 4. 批量 ML 评分(分批处理避免显存溢出)
|
||||
batch_size = 2048
|
||||
ml_scores = []
|
||||
for i in range(0, len(sequences), batch_size):
|
||||
batch_seq = sequences[i:i+batch_size]
|
||||
batch_scores = ml_scorer.score_batch(batch_seq)
|
||||
ml_scores.append(batch_scores)
|
||||
ml_scores = np.concatenate(ml_scores) if ml_scores else np.zeros(len(sequences))
|
||||
|
||||
# 5. 融合得分
|
||||
w1, w2 = config['rule_weight'], config['ml_weight']
|
||||
final_scores = w1 * rule_scores + w2 * ml_scores
|
||||
|
||||
# 6. 判断异动
|
||||
is_anomaly = (
|
||||
(rule_scores >= config['rule_trigger']) |
|
||||
(ml_scores >= config['ml_trigger']) |
|
||||
(final_scores >= config['fusion_trigger'])
|
||||
)
|
||||
|
||||
# 7. 应用冷却期(按概念+时间排序后处理)
|
||||
info_df['rule_score'] = rule_scores
|
||||
info_df['ml_score'] = ml_scores
|
||||
info_df['final_score'] = final_scores
|
||||
info_df['is_anomaly'] = is_anomaly
|
||||
info_df['triggered_rules'] = triggered_rules
|
||||
|
||||
# 只保留异动
|
||||
anomaly_df = info_df[info_df['is_anomaly']].copy()
|
||||
|
||||
if len(anomaly_df) == 0:
|
||||
return []
|
||||
|
||||
# 应用冷却期
|
||||
anomaly_df = anomaly_df.sort_values(['concept_id', 'timestamp'])
|
||||
cooldown = {}
|
||||
keep_mask = []
|
||||
|
||||
for _, row in anomaly_df.iterrows():
|
||||
cid = row['concept_id']
|
||||
ts = row['timestamp']
|
||||
|
||||
if cid in cooldown:
|
||||
try:
|
||||
diff = (ts - cooldown[cid]).total_seconds() / 60
|
||||
except:
|
||||
diff = config['cooldown_minutes'] + 1
|
||||
|
||||
if diff < config['cooldown_minutes']:
|
||||
keep_mask.append(False)
|
||||
continue
|
||||
|
||||
cooldown[cid] = ts
|
||||
keep_mask.append(True)
|
||||
|
||||
anomaly_df = anomaly_df[keep_mask]
|
||||
|
||||
# 8. 按时间分组,每分钟最多 max_alerts_per_minute 个
|
||||
alerts = []
|
||||
for ts, group in anomaly_df.groupby('timestamp'):
|
||||
group = group.nlargest(config['max_alerts_per_minute'], 'final_score')
|
||||
|
||||
for _, row in group.iterrows():
|
||||
alpha = row['alpha']
|
||||
if alpha >= 1.5:
|
||||
atype = 'surge_up'
|
||||
elif alpha <= -1.5:
|
||||
atype = 'surge_down'
|
||||
elif row['amt_ratio'] >= 3.0:
|
||||
atype = 'volume_spike'
|
||||
else:
|
||||
atype = 'unknown'
|
||||
|
||||
rule_score = row['rule_score']
|
||||
ml_score = row['ml_score']
|
||||
final_score = row['final_score']
|
||||
|
||||
if rule_score >= config['rule_trigger']:
|
||||
trigger = f'规则强信号({rule_score:.0f}分)'
|
||||
elif ml_score >= config['ml_trigger']:
|
||||
trigger = f'ML强信号({ml_score:.0f}分)'
|
||||
else:
|
||||
trigger = f'融合触发({final_score:.0f}分)'
|
||||
|
||||
alerts.append({
|
||||
'concept_id': row['concept_id'],
|
||||
'alert_time': row['timestamp'],
|
||||
'trade_date': date,
|
||||
'alert_type': atype,
|
||||
'final_score': final_score,
|
||||
'rule_score': rule_score,
|
||||
'ml_score': ml_score,
|
||||
'trigger_reason': trigger,
|
||||
'triggered_rules': row['triggered_rules'],
|
||||
'alpha': alpha,
|
||||
'alpha_delta': row['alpha_delta'],
|
||||
'amt_ratio': row['amt_ratio'],
|
||||
'amt_delta': row['amt_delta'],
|
||||
'rank_pct': row['rank_pct'],
|
||||
'limit_up_ratio': row['limit_up_ratio'],
|
||||
'stock_count': row['stock_count'],
|
||||
'total_amt': row['total_amt'],
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ==================== 数据加载 ====================
|
||||
|
||||
def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]:
|
||||
file_path = Path(data_dir) / f"features_{date}.parquet"
|
||||
if not file_path.exists():
|
||||
return None
|
||||
return pd.read_parquet(file_path)
|
||||
|
||||
|
||||
def get_available_dates(data_dir: str, start: str, end: str) -> List[str]:
|
||||
data_path = Path(data_dir)
|
||||
dates = []
|
||||
for f in sorted(data_path.glob("features_*.parquet")):
|
||||
d = f.stem.replace('features_', '')
|
||||
if start <= d <= end:
|
||||
dates.append(d)
|
||||
return dates
|
||||
|
||||
|
||||
def get_prev_trading_day(data_dir: str, date: str) -> Optional[str]:
|
||||
"""获取给定日期之前最近的有数据的交易日"""
|
||||
data_path = Path(data_dir)
|
||||
all_dates = sorted([f.stem.replace('features_', '') for f in data_path.glob("features_*.parquet")])
|
||||
|
||||
for i, d in enumerate(all_dates):
|
||||
if d == date and i > 0:
|
||||
return all_dates[i - 1]
|
||||
return None
|
||||
|
||||
|
||||
def export_to_csv(alerts: List[Dict], path: str):
|
||||
if alerts:
|
||||
pd.DataFrame(alerts).to_csv(path, index=False, encoding='utf-8-sig')
|
||||
print(f"已导出: {path}")
|
||||
|
||||
|
||||
# ==================== 数据库写入 ====================
|
||||
|
||||
def init_db_table():
|
||||
"""
|
||||
初始化数据库表(如果不存在则创建)
|
||||
|
||||
表结构说明:
|
||||
- concept_id: 概念ID
|
||||
- alert_time: 异动时间(精确到分钟)
|
||||
- trade_date: 交易日期
|
||||
- alert_type: 异动类型(surge_up/surge_down/volume_spike/unknown)
|
||||
- final_score: 最终得分(0-100)
|
||||
- rule_score: 规则得分(0-100)
|
||||
- ml_score: ML得分(0-100)
|
||||
- trigger_reason: 触发原因
|
||||
- alpha: 超额收益率
|
||||
- alpha_delta: alpha变化速度
|
||||
- amt_ratio: 成交额放大倍数
|
||||
- rank_pct: 排名百分位
|
||||
- stock_count: 概念股票数量
|
||||
- triggered_rules: 触发的规则列表(JSON)
|
||||
"""
|
||||
create_sql = text("""
|
||||
CREATE TABLE IF NOT EXISTS concept_anomaly_hybrid (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
concept_id VARCHAR(64) NOT NULL,
|
||||
alert_time DATETIME NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
alert_type VARCHAR(32) NOT NULL,
|
||||
final_score FLOAT NOT NULL,
|
||||
rule_score FLOAT NOT NULL,
|
||||
ml_score FLOAT NOT NULL,
|
||||
trigger_reason VARCHAR(64),
|
||||
alpha FLOAT,
|
||||
alpha_delta FLOAT,
|
||||
amt_ratio FLOAT,
|
||||
amt_delta FLOAT,
|
||||
rank_pct FLOAT,
|
||||
limit_up_ratio FLOAT,
|
||||
stock_count INT,
|
||||
total_amt FLOAT,
|
||||
triggered_rules JSON,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_concept_time (concept_id, alert_time, trade_date),
|
||||
INDEX idx_trade_date (trade_date),
|
||||
INDEX idx_concept_id (concept_id),
|
||||
INDEX idx_final_score (final_score),
|
||||
INDEX idx_alert_type (alert_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='概念异动检测结果(融合版)'
|
||||
""")
|
||||
|
||||
with MYSQL_ENGINE.begin() as conn:
|
||||
conn.execute(create_sql)
|
||||
print("数据库表已就绪: concept_anomaly_hybrid")
|
||||
|
||||
|
||||
def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int:
|
||||
"""
|
||||
保存异动到 MySQL
|
||||
|
||||
Args:
|
||||
alerts: 异动列表
|
||||
dry_run: 是否只模拟,不实际写入
|
||||
|
||||
Returns:
|
||||
实际保存的记录数
|
||||
"""
|
||||
if not alerts:
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
print(f" [Dry Run] 将写入 {len(alerts)} 条异动")
|
||||
return len(alerts)
|
||||
|
||||
saved = 0
|
||||
skipped = 0
|
||||
|
||||
with MYSQL_ENGINE.begin() as conn:
|
||||
for alert in alerts:
|
||||
try:
|
||||
# 检查是否已存在(使用 INSERT IGNORE 更高效)
|
||||
insert_sql = text("""
|
||||
INSERT IGNORE INTO concept_anomaly_hybrid
|
||||
(concept_id, alert_time, trade_date, alert_type,
|
||||
final_score, rule_score, ml_score, trigger_reason,
|
||||
alpha, alpha_delta, amt_ratio, amt_delta,
|
||||
rank_pct, limit_up_ratio, stock_count, total_amt,
|
||||
triggered_rules)
|
||||
VALUES
|
||||
(:concept_id, :alert_time, :trade_date, :alert_type,
|
||||
:final_score, :rule_score, :ml_score, :trigger_reason,
|
||||
:alpha, :alpha_delta, :amt_ratio, :amt_delta,
|
||||
:rank_pct, :limit_up_ratio, :stock_count, :total_amt,
|
||||
:triggered_rules)
|
||||
""")
|
||||
|
||||
result = conn.execute(insert_sql, {
|
||||
'concept_id': alert['concept_id'],
|
||||
'alert_time': alert['alert_time'],
|
||||
'trade_date': alert['trade_date'],
|
||||
'alert_type': alert['alert_type'],
|
||||
'final_score': alert['final_score'],
|
||||
'rule_score': alert['rule_score'],
|
||||
'ml_score': alert['ml_score'],
|
||||
'trigger_reason': alert['trigger_reason'],
|
||||
'alpha': alert.get('alpha', 0),
|
||||
'alpha_delta': alert.get('alpha_delta', 0),
|
||||
'amt_ratio': alert.get('amt_ratio', 1),
|
||||
'amt_delta': alert.get('amt_delta', 0),
|
||||
'rank_pct': alert.get('rank_pct', 0.5),
|
||||
'limit_up_ratio': alert.get('limit_up_ratio', 0),
|
||||
'stock_count': alert.get('stock_count', 0),
|
||||
'total_amt': alert.get('total_amt', 0),
|
||||
'triggered_rules': json.dumps(alert.get('triggered_rules', []), ensure_ascii=False),
|
||||
})
|
||||
|
||||
if result.rowcount > 0:
|
||||
saved += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" 保存失败: {alert['concept_id']} @ {alert['alert_time']} - {e}")
|
||||
|
||||
if skipped > 0:
|
||||
print(f" 跳过 {skipped} 条重复记录")
|
||||
|
||||
return saved
|
||||
|
||||
|
||||
def clear_alerts_by_date(trade_date: str) -> int:
|
||||
"""清除指定日期的异动记录(用于重新回测)"""
|
||||
with MYSQL_ENGINE.begin() as conn:
|
||||
result = conn.execute(
|
||||
text("DELETE FROM concept_anomaly_hybrid WHERE trade_date = :trade_date"),
|
||||
{'trade_date': trade_date}
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
def analyze_alerts(alerts: List[Dict]):
|
||||
if not alerts:
|
||||
print("无异动")
|
||||
return
|
||||
|
||||
df = pd.DataFrame(alerts)
|
||||
print(f"\n总异动: {len(alerts)}")
|
||||
print(f"\n类型分布:\n{df['alert_type'].value_counts()}")
|
||||
print(f"\n得分统计:")
|
||||
print(f" 最终: {df['final_score'].mean():.1f} (max: {df['final_score'].max():.1f})")
|
||||
print(f" 规则: {df['rule_score'].mean():.1f} (max: {df['rule_score'].max():.1f})")
|
||||
print(f" ML: {df['ml_score'].mean():.1f} (max: {df['ml_score'].max():.1f})")
|
||||
|
||||
trigger_type = df['trigger_reason'].apply(
|
||||
lambda x: '规则' if '规则' in x else ('ML' if 'ML' in x else '融合')
|
||||
)
|
||||
print(f"\n触发来源:\n{trigger_type.value_counts()}")
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='快速融合异动回测')
|
||||
parser.add_argument('--data_dir', default='ml/data')
|
||||
parser.add_argument('--checkpoint_dir', default='ml/checkpoints')
|
||||
parser.add_argument('--start', required=True)
|
||||
parser.add_argument('--end', default=None)
|
||||
parser.add_argument('--dry-run', action='store_true', help='模拟运行,不写入数据库')
|
||||
parser.add_argument('--export-csv', default=None, help='导出 CSV 文件路径')
|
||||
parser.add_argument('--save-db', action='store_true', help='保存结果到数据库')
|
||||
parser.add_argument('--clear-first', action='store_true', help='写入前先清除该日期的旧数据')
|
||||
parser.add_argument('--device', default='auto')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.end is None:
|
||||
args.end = args.start
|
||||
|
||||
print("=" * 60)
|
||||
print("快速融合异动回测")
|
||||
print("=" * 60)
|
||||
print(f"日期: {args.start} ~ {args.end}")
|
||||
print(f"设备: {args.device}")
|
||||
print(f"保存数据库: {args.save_db}")
|
||||
print("=" * 60)
|
||||
|
||||
# 初始化数据库表(如果需要保存)
|
||||
if args.save_db and not args.dry_run:
|
||||
init_db_table()
|
||||
|
||||
# 初始化 ML 评分器
|
||||
ml_scorer = FastMLScorer(args.checkpoint_dir, args.device)
|
||||
|
||||
# 获取日期
|
||||
dates = get_available_dates(args.data_dir, args.start, args.end)
|
||||
if not dates:
|
||||
print("无数据")
|
||||
return
|
||||
|
||||
print(f"找到 {len(dates)} 天数据\n")
|
||||
|
||||
# 回测(支持跨日序列)
|
||||
all_alerts = []
|
||||
total_saved = 0
|
||||
prev_df = None # 缓存前一天数据
|
||||
|
||||
for i, date in enumerate(tqdm(dates, desc="回测")):
|
||||
df = load_daily_features(args.data_dir, date)
|
||||
if df is None or df.empty:
|
||||
prev_df = None # 当天无数据,清空缓存
|
||||
continue
|
||||
|
||||
# 第一天需要加载前一天数据(如果存在)
|
||||
if i == 0 and prev_df is None:
|
||||
prev_date = get_prev_trading_day(args.data_dir, date)
|
||||
if prev_date:
|
||||
prev_df = load_daily_features(args.data_dir, prev_date)
|
||||
if prev_df is not None:
|
||||
tqdm.write(f" 加载前一天数据: {prev_date}")
|
||||
|
||||
alerts = backtest_single_day_fast(ml_scorer, df, date, CONFIG, prev_df)
|
||||
all_alerts.extend(alerts)
|
||||
|
||||
# 保存到数据库
|
||||
if args.save_db and alerts:
|
||||
if args.clear_first and not args.dry_run:
|
||||
cleared = clear_alerts_by_date(date)
|
||||
if cleared > 0:
|
||||
tqdm.write(f" 清除 {date} 旧数据: {cleared} 条")
|
||||
|
||||
saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run)
|
||||
total_saved += saved
|
||||
tqdm.write(f" {date}: {len(alerts)} 个异动, 保存 {saved} 条")
|
||||
elif alerts:
|
||||
tqdm.write(f" {date}: {len(alerts)} 个异动")
|
||||
|
||||
# 当天数据成为下一天的 prev_df
|
||||
prev_df = df
|
||||
|
||||
# 导出 CSV
|
||||
if args.export_csv:
|
||||
export_to_csv(all_alerts, args.export_csv)
|
||||
|
||||
# 分析
|
||||
analyze_alerts(all_alerts)
|
||||
|
||||
print(f"\n总计: {len(all_alerts)} 个异动")
|
||||
if args.save_db:
|
||||
print(f"已保存到数据库: {total_saved} 条")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,481 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
融合异动回测脚本
|
||||
|
||||
使用 HybridAnomalyDetector 进行回测:
|
||||
- 规则评分 + LSTM Autoencoder 融合判断
|
||||
- 输出更丰富的异动信息
|
||||
|
||||
使用方法:
|
||||
python backtest_hybrid.py --start 2024-01-01 --end 2024-12-01
|
||||
python backtest_hybrid.py --start 2024-11-01 --dry-run
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from tqdm import tqdm
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# 添加父目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from detector import HybridAnomalyDetector, create_detector
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
MYSQL_ENGINE = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
|
||||
echo=False
|
||||
)
|
||||
|
||||
FEATURES = [
|
||||
'alpha',
|
||||
'alpha_delta',
|
||||
'amt_ratio',
|
||||
'amt_delta',
|
||||
'rank_pct',
|
||||
'limit_up_ratio',
|
||||
]
|
||||
|
||||
BACKTEST_CONFIG = {
|
||||
'seq_len': 30,
|
||||
'min_alpha_abs': 0.3, # 降低阈值,让规则也能发挥作用
|
||||
'cooldown_minutes': 8,
|
||||
'max_alerts_per_minute': 20,
|
||||
'clip_value': 10.0,
|
||||
}
|
||||
|
||||
|
||||
# ==================== 数据加载 ====================
|
||||
|
||||
def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]:
|
||||
"""加载单天的特征数据"""
|
||||
file_path = Path(data_dir) / f"features_{date}.parquet"
|
||||
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
df = pd.read_parquet(file_path)
|
||||
return df
|
||||
|
||||
|
||||
def get_available_dates(data_dir: str, start_date: str, end_date: str) -> List[str]:
|
||||
"""获取可用的日期列表"""
|
||||
data_path = Path(data_dir)
|
||||
all_files = sorted(data_path.glob("features_*.parquet"))
|
||||
|
||||
dates = []
|
||||
for f in all_files:
|
||||
date = f.stem.replace('features_', '')
|
||||
if start_date <= date <= end_date:
|
||||
dates.append(date)
|
||||
|
||||
return dates
|
||||
|
||||
|
||||
# ==================== 融合回测 ====================
|
||||
|
||||
def backtest_single_day_hybrid(
|
||||
detector: HybridAnomalyDetector,
|
||||
df: pd.DataFrame,
|
||||
date: str,
|
||||
seq_len: int = 30
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
使用融合检测器回测单天数据(批量优化版)
|
||||
"""
|
||||
alerts = []
|
||||
|
||||
# 按概念分组,预先构建字典
|
||||
grouped_dict = {cid: cdf for cid, cdf in df.groupby('concept_id', sort=False)}
|
||||
|
||||
# 冷却记录
|
||||
cooldown = {}
|
||||
|
||||
# 获取所有时间点
|
||||
all_timestamps = sorted(df['timestamp'].unique())
|
||||
|
||||
if len(all_timestamps) < seq_len:
|
||||
return alerts
|
||||
|
||||
# 对每个时间点进行检测
|
||||
for t_idx in range(seq_len - 1, len(all_timestamps)):
|
||||
current_time = all_timestamps[t_idx]
|
||||
window_start_time = all_timestamps[t_idx - seq_len + 1]
|
||||
|
||||
# 批量收集该时刻所有候选概念
|
||||
batch_sequences = []
|
||||
batch_features = []
|
||||
batch_infos = []
|
||||
|
||||
for concept_id, concept_df in grouped_dict.items():
|
||||
# 检查冷却(提前过滤)
|
||||
if concept_id in cooldown:
|
||||
last_alert = cooldown[concept_id]
|
||||
if isinstance(current_time, datetime):
|
||||
time_diff = (current_time - last_alert).total_seconds() / 60
|
||||
else:
|
||||
time_diff = BACKTEST_CONFIG['cooldown_minutes'] + 1
|
||||
if time_diff < BACKTEST_CONFIG['cooldown_minutes']:
|
||||
continue
|
||||
|
||||
# 获取时间窗口内的数据
|
||||
mask = (concept_df['timestamp'] >= window_start_time) & (concept_df['timestamp'] <= current_time)
|
||||
window_df = concept_df.loc[mask]
|
||||
|
||||
if len(window_df) < seq_len:
|
||||
continue
|
||||
|
||||
window_df = window_df.sort_values('timestamp').tail(seq_len)
|
||||
|
||||
# 当前时刻特征
|
||||
current_row = window_df.iloc[-1]
|
||||
alpha = current_row.get('alpha', 0)
|
||||
|
||||
# 过滤微小波动(提前过滤)
|
||||
if abs(alpha) < BACKTEST_CONFIG['min_alpha_abs']:
|
||||
continue
|
||||
|
||||
# 提取特征序列
|
||||
sequence = window_df[FEATURES].values
|
||||
sequence = np.nan_to_num(sequence, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
sequence = np.clip(sequence, -BACKTEST_CONFIG['clip_value'], BACKTEST_CONFIG['clip_value'])
|
||||
|
||||
current_features = {
|
||||
'alpha': alpha,
|
||||
'alpha_delta': current_row.get('alpha_delta', 0),
|
||||
'amt_ratio': current_row.get('amt_ratio', 1),
|
||||
'amt_delta': current_row.get('amt_delta', 0),
|
||||
'rank_pct': current_row.get('rank_pct', 0.5),
|
||||
'limit_up_ratio': current_row.get('limit_up_ratio', 0),
|
||||
}
|
||||
|
||||
batch_sequences.append(sequence)
|
||||
batch_features.append(current_features)
|
||||
batch_infos.append({
|
||||
'concept_id': concept_id,
|
||||
'stock_count': current_row.get('stock_count', 0),
|
||||
'total_amt': current_row.get('total_amt', 0),
|
||||
})
|
||||
|
||||
if not batch_sequences:
|
||||
continue
|
||||
|
||||
# 批量 ML 推理
|
||||
sequences_array = np.array(batch_sequences)
|
||||
ml_scores = detector.ml_scorer.score(sequences_array) if detector.ml_scorer.is_ready() else [0.0] * len(batch_sequences)
|
||||
if isinstance(ml_scores, float):
|
||||
ml_scores = [ml_scores]
|
||||
|
||||
# 批量规则评分 + 融合
|
||||
minute_alerts = []
|
||||
for i, (features, info) in enumerate(zip(batch_features, batch_infos)):
|
||||
concept_id = info['concept_id']
|
||||
|
||||
# 规则评分
|
||||
rule_score, rule_details = detector.rule_scorer.score(features)
|
||||
|
||||
# ML 评分
|
||||
ml_score = ml_scores[i] if i < len(ml_scores) else 0.0
|
||||
|
||||
# 融合
|
||||
w1 = detector.config['rule_weight']
|
||||
w2 = detector.config['ml_weight']
|
||||
final_score = w1 * rule_score + w2 * ml_score
|
||||
|
||||
# 判断是否异动
|
||||
is_anomaly = False
|
||||
trigger_reason = ''
|
||||
|
||||
if rule_score >= detector.config['rule_trigger']:
|
||||
is_anomaly = True
|
||||
trigger_reason = f'规则强信号({rule_score:.0f}分)'
|
||||
elif ml_score >= detector.config['ml_trigger']:
|
||||
is_anomaly = True
|
||||
trigger_reason = f'ML强信号({ml_score:.0f}分)'
|
||||
elif final_score >= detector.config['fusion_trigger']:
|
||||
is_anomaly = True
|
||||
trigger_reason = f'融合触发({final_score:.0f}分)'
|
||||
|
||||
if not is_anomaly:
|
||||
continue
|
||||
|
||||
# 异动类型
|
||||
alpha = features.get('alpha', 0)
|
||||
if alpha >= 1.5:
|
||||
anomaly_type = 'surge_up'
|
||||
elif alpha <= -1.5:
|
||||
anomaly_type = 'surge_down'
|
||||
elif features.get('amt_ratio', 1) >= 3.0:
|
||||
anomaly_type = 'volume_spike'
|
||||
else:
|
||||
anomaly_type = 'unknown'
|
||||
|
||||
alert = {
|
||||
'concept_id': concept_id,
|
||||
'alert_time': current_time,
|
||||
'trade_date': date,
|
||||
'alert_type': anomaly_type,
|
||||
'final_score': final_score,
|
||||
'rule_score': rule_score,
|
||||
'ml_score': ml_score,
|
||||
'trigger_reason': trigger_reason,
|
||||
'triggered_rules': list(rule_details.keys()),
|
||||
**features,
|
||||
**info,
|
||||
}
|
||||
|
||||
minute_alerts.append(alert)
|
||||
cooldown[concept_id] = current_time
|
||||
|
||||
# 按最终得分排序
|
||||
minute_alerts.sort(key=lambda x: x['final_score'], reverse=True)
|
||||
alerts.extend(minute_alerts[:BACKTEST_CONFIG['max_alerts_per_minute']])
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ==================== 数据库写入 ====================
|
||||
|
||||
def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int:
|
||||
"""保存异动到 MySQL(增强版字段)"""
|
||||
if not alerts:
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
print(f" [Dry Run] 将写入 {len(alerts)} 条异动")
|
||||
return len(alerts)
|
||||
|
||||
saved = 0
|
||||
with MYSQL_ENGINE.begin() as conn:
|
||||
for alert in alerts:
|
||||
try:
|
||||
# 检查是否已存在
|
||||
check_sql = text("""
|
||||
SELECT id FROM concept_minute_alert
|
||||
WHERE concept_id = :concept_id
|
||||
AND alert_time = :alert_time
|
||||
AND trade_date = :trade_date
|
||||
""")
|
||||
exists = conn.execute(check_sql, {
|
||||
'concept_id': alert['concept_id'],
|
||||
'alert_time': alert['alert_time'],
|
||||
'trade_date': alert['trade_date'],
|
||||
}).fetchone()
|
||||
|
||||
if exists:
|
||||
continue
|
||||
|
||||
# 插入新记录
|
||||
insert_sql = text("""
|
||||
INSERT INTO concept_minute_alert
|
||||
(concept_id, concept_name, alert_time, alert_type, trade_date,
|
||||
change_pct, zscore, importance_score, stock_count, extra_info)
|
||||
VALUES
|
||||
(:concept_id, :concept_name, :alert_time, :alert_type, :trade_date,
|
||||
:change_pct, :zscore, :importance_score, :stock_count, :extra_info)
|
||||
""")
|
||||
|
||||
extra_info = {
|
||||
'detection_method': 'hybrid',
|
||||
'final_score': alert['final_score'],
|
||||
'rule_score': alert['rule_score'],
|
||||
'ml_score': alert['ml_score'],
|
||||
'trigger_reason': alert['trigger_reason'],
|
||||
'triggered_rules': alert['triggered_rules'],
|
||||
'alpha': alert.get('alpha', 0),
|
||||
'alpha_delta': alert.get('alpha_delta', 0),
|
||||
'amt_ratio': alert.get('amt_ratio', 1),
|
||||
}
|
||||
|
||||
conn.execute(insert_sql, {
|
||||
'concept_id': alert['concept_id'],
|
||||
'concept_name': alert.get('concept_name', ''),
|
||||
'alert_time': alert['alert_time'],
|
||||
'alert_type': alert['alert_type'],
|
||||
'trade_date': alert['trade_date'],
|
||||
'change_pct': alert.get('alpha', 0),
|
||||
'zscore': alert['final_score'], # 用最终得分作为 zscore
|
||||
'importance_score': alert['final_score'],
|
||||
'stock_count': alert.get('stock_count', 0),
|
||||
'extra_info': json.dumps(extra_info, ensure_ascii=False)
|
||||
})
|
||||
|
||||
saved += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" 保存失败: {alert['concept_id']} - {e}")
|
||||
|
||||
return saved
|
||||
|
||||
|
||||
def export_alerts_to_csv(alerts: List[Dict], output_path: str):
|
||||
"""导出异动到 CSV"""
|
||||
if not alerts:
|
||||
return
|
||||
|
||||
df = pd.DataFrame(alerts)
|
||||
df.to_csv(output_path, index=False, encoding='utf-8-sig')
|
||||
print(f"已导出到: {output_path}")
|
||||
|
||||
|
||||
# ==================== 统计分析 ====================
|
||||
|
||||
def analyze_alerts(alerts: List[Dict]):
|
||||
"""分析异动结果"""
|
||||
if not alerts:
|
||||
print("无异动数据")
|
||||
return
|
||||
|
||||
df = pd.DataFrame(alerts)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("异动统计分析")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 基本统计
|
||||
print(f"\n总异动数: {len(alerts)}")
|
||||
|
||||
# 2. 按类型统计
|
||||
print(f"\n异动类型分布:")
|
||||
print(df['alert_type'].value_counts())
|
||||
|
||||
# 3. 得分统计
|
||||
print(f"\n得分统计:")
|
||||
print(f" 最终得分 - Mean: {df['final_score'].mean():.1f}, Max: {df['final_score'].max():.1f}")
|
||||
print(f" 规则得分 - Mean: {df['rule_score'].mean():.1f}, Max: {df['rule_score'].max():.1f}")
|
||||
print(f" ML得分 - Mean: {df['ml_score'].mean():.1f}, Max: {df['ml_score'].max():.1f}")
|
||||
|
||||
# 4. 触发来源分析
|
||||
print(f"\n触发来源分析:")
|
||||
trigger_counts = df['trigger_reason'].apply(
|
||||
lambda x: '规则' if '规则' in x else ('ML' if 'ML' in x else '融合')
|
||||
).value_counts()
|
||||
print(trigger_counts)
|
||||
|
||||
# 5. 规则触发频率
|
||||
all_rules = []
|
||||
for rules in df['triggered_rules']:
|
||||
if isinstance(rules, list):
|
||||
all_rules.extend(rules)
|
||||
|
||||
if all_rules:
|
||||
print(f"\n最常触发的规则 (Top 10):")
|
||||
from collections import Counter
|
||||
rule_counts = Counter(all_rules)
|
||||
for rule, count in rule_counts.most_common(10):
|
||||
print(f" {rule}: {count}")
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='融合异动回测')
|
||||
parser.add_argument('--data_dir', type=str, default='ml/data',
|
||||
help='特征数据目录')
|
||||
parser.add_argument('--checkpoint_dir', type=str, default='ml/checkpoints',
|
||||
help='模型检查点目录')
|
||||
parser.add_argument('--start', type=str, required=True,
|
||||
help='开始日期 (YYYY-MM-DD)')
|
||||
parser.add_argument('--end', type=str, default=None,
|
||||
help='结束日期 (YYYY-MM-DD),默认=start')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='只计算,不写入数据库')
|
||||
parser.add_argument('--export-csv', type=str, default=None,
|
||||
help='导出 CSV 文件路径')
|
||||
parser.add_argument('--rule-weight', type=float, default=0.6,
|
||||
help='规则权重 (0-1)')
|
||||
parser.add_argument('--ml-weight', type=float, default=0.4,
|
||||
help='ML权重 (0-1)')
|
||||
parser.add_argument('--device', type=str, default='cuda',
|
||||
help='设备 (cuda/cpu),默认 cuda')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.end is None:
|
||||
args.end = args.start
|
||||
|
||||
print("=" * 60)
|
||||
print("融合异动回测 (规则 + LSTM)")
|
||||
print("=" * 60)
|
||||
print(f"日期范围: {args.start} ~ {args.end}")
|
||||
print(f"数据目录: {args.data_dir}")
|
||||
print(f"模型目录: {args.checkpoint_dir}")
|
||||
print(f"规则权重: {args.rule_weight}")
|
||||
print(f"ML权重: {args.ml_weight}")
|
||||
print(f"设备: {args.device}")
|
||||
print(f"Dry Run: {args.dry_run}")
|
||||
print("=" * 60)
|
||||
|
||||
# 初始化融合检测器(使用 GPU)
|
||||
config = {
|
||||
'rule_weight': args.rule_weight,
|
||||
'ml_weight': args.ml_weight,
|
||||
}
|
||||
|
||||
# 修改 detector.py 中 MLScorer 的设备
|
||||
from detector import HybridAnomalyDetector
|
||||
detector = HybridAnomalyDetector(config, args.checkpoint_dir, device=args.device)
|
||||
|
||||
# 获取可用日期
|
||||
dates = get_available_dates(args.data_dir, args.start, args.end)
|
||||
|
||||
if not dates:
|
||||
print(f"未找到 {args.start} ~ {args.end} 范围内的数据")
|
||||
return
|
||||
|
||||
print(f"\n找到 {len(dates)} 天的数据")
|
||||
|
||||
# 回测
|
||||
all_alerts = []
|
||||
total_saved = 0
|
||||
|
||||
for date in tqdm(dates, desc="回测进度"):
|
||||
df = load_daily_features(args.data_dir, date)
|
||||
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
|
||||
alerts = backtest_single_day_hybrid(
|
||||
detector, df, date,
|
||||
seq_len=BACKTEST_CONFIG['seq_len']
|
||||
)
|
||||
|
||||
if alerts:
|
||||
all_alerts.extend(alerts)
|
||||
|
||||
saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run)
|
||||
total_saved += saved
|
||||
|
||||
if not args.dry_run:
|
||||
tqdm.write(f" {date}: 检测到 {len(alerts)} 个异动,保存 {saved} 条")
|
||||
|
||||
# 导出 CSV
|
||||
if args.export_csv and all_alerts:
|
||||
export_alerts_to_csv(all_alerts, args.export_csv)
|
||||
|
||||
# 统计分析
|
||||
analyze_alerts(all_alerts)
|
||||
|
||||
# 汇总
|
||||
print("\n" + "=" * 60)
|
||||
print("回测完成!")
|
||||
print("=" * 60)
|
||||
print(f"总计检测到: {len(all_alerts)} 个异动")
|
||||
print(f"保存到数据库: {total_saved} 条")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
V2 回测脚本 - 验证时间片对齐 + 持续性确认的效果
|
||||
|
||||
回测指标:
|
||||
1. 准确率:异动后 N 分钟内 alpha 是否继续上涨/下跌
|
||||
2. 虚警率:多少异动是噪音
|
||||
3. 持续性:平均异动持续时长
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from tqdm import tqdm
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from ml.detector_v2 import AnomalyDetectorV2, CONFIG
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
MYSQL_ENGINE = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
|
||||
echo=False
|
||||
)
|
||||
|
||||
|
||||
# ==================== 回测评估 ====================
|
||||
|
||||
def evaluate_alerts(
|
||||
alerts: List[Dict],
|
||||
raw_data: pd.DataFrame,
|
||||
lookahead_minutes: int = 10
|
||||
) -> Dict:
|
||||
"""
|
||||
评估异动质量
|
||||
|
||||
指标:
|
||||
1. 方向正确率:异动后 N 分钟 alpha 方向是否一致
|
||||
2. 持续率:异动后 N 分钟内有多少时刻 alpha 保持同向
|
||||
3. 峰值收益:异动后 N 分钟内的最大 alpha
|
||||
"""
|
||||
if not alerts:
|
||||
return {'accuracy': 0, 'sustained_rate': 0, 'avg_peak': 0, 'total_alerts': 0}
|
||||
|
||||
results = []
|
||||
|
||||
for alert in alerts:
|
||||
concept_id = alert['concept_id']
|
||||
alert_time = alert['alert_time']
|
||||
alert_alpha = alert['alpha']
|
||||
is_up = alert_alpha > 0
|
||||
|
||||
# 获取该概念在异动后的数据
|
||||
concept_data = raw_data[
|
||||
(raw_data['concept_id'] == concept_id) &
|
||||
(raw_data['timestamp'] > alert_time)
|
||||
].head(lookahead_minutes)
|
||||
|
||||
if len(concept_data) < 3:
|
||||
continue
|
||||
|
||||
future_alphas = concept_data['alpha'].values
|
||||
|
||||
# 方向正确:未来 alpha 平均值与当前同向
|
||||
avg_future_alpha = np.mean(future_alphas)
|
||||
direction_correct = (is_up and avg_future_alpha > 0) or (not is_up and avg_future_alpha < 0)
|
||||
|
||||
# 持续率:有多少时刻保持同向
|
||||
if is_up:
|
||||
sustained_count = sum(1 for a in future_alphas if a > 0)
|
||||
else:
|
||||
sustained_count = sum(1 for a in future_alphas if a < 0)
|
||||
sustained_rate = sustained_count / len(future_alphas)
|
||||
|
||||
# 峰值收益
|
||||
if is_up:
|
||||
peak = max(future_alphas)
|
||||
else:
|
||||
peak = min(future_alphas)
|
||||
|
||||
results.append({
|
||||
'direction_correct': direction_correct,
|
||||
'sustained_rate': sustained_rate,
|
||||
'peak': peak,
|
||||
'alert_alpha': alert_alpha,
|
||||
})
|
||||
|
||||
if not results:
|
||||
return {'accuracy': 0, 'sustained_rate': 0, 'avg_peak': 0, 'total_alerts': 0}
|
||||
|
||||
return {
|
||||
'accuracy': np.mean([r['direction_correct'] for r in results]),
|
||||
'sustained_rate': np.mean([r['sustained_rate'] for r in results]),
|
||||
'avg_peak': np.mean([abs(r['peak']) for r in results]),
|
||||
'total_alerts': len(alerts),
|
||||
'evaluated_alerts': len(results),
|
||||
}
|
||||
|
||||
|
||||
def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int:
|
||||
"""保存异动到 MySQL"""
|
||||
if not alerts or dry_run:
|
||||
return 0
|
||||
|
||||
# 确保表存在
|
||||
with MYSQL_ENGINE.begin() as conn:
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS concept_anomaly_v2 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
concept_id VARCHAR(64) NOT NULL,
|
||||
alert_time DATETIME NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
alert_type VARCHAR(32) NOT NULL,
|
||||
final_score FLOAT NOT NULL,
|
||||
rule_score FLOAT NOT NULL,
|
||||
ml_score FLOAT NOT NULL,
|
||||
trigger_reason VARCHAR(128),
|
||||
confirm_ratio FLOAT,
|
||||
alpha FLOAT,
|
||||
alpha_zscore FLOAT,
|
||||
amt_zscore FLOAT,
|
||||
rank_zscore FLOAT,
|
||||
momentum_3m FLOAT,
|
||||
momentum_5m FLOAT,
|
||||
limit_up_ratio FLOAT,
|
||||
triggered_rules JSON,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_concept_time (concept_id, alert_time, trade_date),
|
||||
INDEX idx_trade_date (trade_date),
|
||||
INDEX idx_final_score (final_score)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='概念异动 V2(时间片对齐+持续确认)'
|
||||
"""))
|
||||
|
||||
# 插入数据
|
||||
saved = 0
|
||||
with MYSQL_ENGINE.begin() as conn:
|
||||
for alert in alerts:
|
||||
try:
|
||||
conn.execute(text("""
|
||||
INSERT IGNORE INTO concept_anomaly_v2
|
||||
(concept_id, alert_time, trade_date, alert_type,
|
||||
final_score, rule_score, ml_score, trigger_reason, confirm_ratio,
|
||||
alpha, alpha_zscore, amt_zscore, rank_zscore,
|
||||
momentum_3m, momentum_5m, limit_up_ratio, triggered_rules)
|
||||
VALUES
|
||||
(:concept_id, :alert_time, :trade_date, :alert_type,
|
||||
:final_score, :rule_score, :ml_score, :trigger_reason, :confirm_ratio,
|
||||
:alpha, :alpha_zscore, :amt_zscore, :rank_zscore,
|
||||
:momentum_3m, :momentum_5m, :limit_up_ratio, :triggered_rules)
|
||||
"""), {
|
||||
'concept_id': alert['concept_id'],
|
||||
'alert_time': alert['alert_time'],
|
||||
'trade_date': alert['trade_date'],
|
||||
'alert_type': alert['alert_type'],
|
||||
'final_score': alert['final_score'],
|
||||
'rule_score': alert['rule_score'],
|
||||
'ml_score': alert['ml_score'],
|
||||
'trigger_reason': alert['trigger_reason'],
|
||||
'confirm_ratio': alert.get('confirm_ratio', 0),
|
||||
'alpha': alert['alpha'],
|
||||
'alpha_zscore': alert.get('alpha_zscore', 0),
|
||||
'amt_zscore': alert.get('amt_zscore', 0),
|
||||
'rank_zscore': alert.get('rank_zscore', 0),
|
||||
'momentum_3m': alert.get('momentum_3m', 0),
|
||||
'momentum_5m': alert.get('momentum_5m', 0),
|
||||
'limit_up_ratio': alert.get('limit_up_ratio', 0),
|
||||
'triggered_rules': json.dumps(alert.get('triggered_rules', [])),
|
||||
})
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
print(f"保存失败: {e}")
|
||||
|
||||
return saved
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='V2 回测')
|
||||
parser.add_argument('--start', type=str, required=True, help='开始日期')
|
||||
parser.add_argument('--end', type=str, default=None, help='结束日期')
|
||||
parser.add_argument('--model_dir', type=str, default='ml/checkpoints_v2')
|
||||
parser.add_argument('--baseline_dir', type=str, default='ml/data_v2/baselines')
|
||||
parser.add_argument('--save', action='store_true', help='保存到数据库')
|
||||
parser.add_argument('--lookahead', type=int, default=10, help='评估前瞻时间(分钟)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
end_date = args.end or args.start
|
||||
|
||||
print("=" * 60)
|
||||
print("V2 回测 - 时间片对齐 + 持续性确认")
|
||||
print("=" * 60)
|
||||
print(f"日期范围: {args.start} ~ {end_date}")
|
||||
print(f"模型目录: {args.model_dir}")
|
||||
print(f"评估前瞻: {args.lookahead} 分钟")
|
||||
|
||||
# 初始化检测器
|
||||
detector = AnomalyDetectorV2(
|
||||
model_dir=args.model_dir,
|
||||
baseline_dir=args.baseline_dir
|
||||
)
|
||||
|
||||
# 获取交易日
|
||||
from prepare_data_v2 import get_trading_days
|
||||
trading_days = get_trading_days(args.start, end_date)
|
||||
|
||||
if not trading_days:
|
||||
print("无交易日")
|
||||
return
|
||||
|
||||
print(f"交易日数: {len(trading_days)}")
|
||||
|
||||
# 回测统计
|
||||
total_stats = {
|
||||
'total_alerts': 0,
|
||||
'accuracy_sum': 0,
|
||||
'sustained_sum': 0,
|
||||
'peak_sum': 0,
|
||||
'day_count': 0,
|
||||
}
|
||||
|
||||
all_alerts = []
|
||||
|
||||
for trade_date in tqdm(trading_days, desc="回测进度"):
|
||||
# 检测异动
|
||||
alerts = detector.detect(trade_date)
|
||||
|
||||
if not alerts:
|
||||
continue
|
||||
|
||||
all_alerts.extend(alerts)
|
||||
|
||||
# 评估
|
||||
raw_data = detector._compute_raw_features(trade_date)
|
||||
if raw_data.empty:
|
||||
continue
|
||||
|
||||
stats = evaluate_alerts(alerts, raw_data, args.lookahead)
|
||||
|
||||
if stats['evaluated_alerts'] > 0:
|
||||
total_stats['total_alerts'] += stats['total_alerts']
|
||||
total_stats['accuracy_sum'] += stats['accuracy'] * stats['evaluated_alerts']
|
||||
total_stats['sustained_sum'] += stats['sustained_rate'] * stats['evaluated_alerts']
|
||||
total_stats['peak_sum'] += stats['avg_peak'] * stats['evaluated_alerts']
|
||||
total_stats['day_count'] += 1
|
||||
|
||||
print(f"\n[{trade_date}] 异动: {stats['total_alerts']}, "
|
||||
f"准确率: {stats['accuracy']:.1%}, "
|
||||
f"持续率: {stats['sustained_rate']:.1%}, "
|
||||
f"峰值: {stats['avg_peak']:.2f}%")
|
||||
|
||||
# 汇总
|
||||
print("\n" + "=" * 60)
|
||||
print("回测汇总")
|
||||
print("=" * 60)
|
||||
|
||||
if total_stats['total_alerts'] > 0:
|
||||
avg_accuracy = total_stats['accuracy_sum'] / total_stats['total_alerts']
|
||||
avg_sustained = total_stats['sustained_sum'] / total_stats['total_alerts']
|
||||
avg_peak = total_stats['peak_sum'] / total_stats['total_alerts']
|
||||
|
||||
print(f"总异动数: {total_stats['total_alerts']}")
|
||||
print(f"回测天数: {total_stats['day_count']}")
|
||||
print(f"平均每天: {total_stats['total_alerts'] / max(1, total_stats['day_count']):.1f} 个")
|
||||
print(f"方向准确率: {avg_accuracy:.1%}")
|
||||
print(f"持续率: {avg_sustained:.1%}")
|
||||
print(f"平均峰值: {avg_peak:.2f}%")
|
||||
else:
|
||||
print("无异动检测结果")
|
||||
|
||||
# 保存
|
||||
if args.save and all_alerts:
|
||||
print(f"\n保存 {len(all_alerts)} 条异动到数据库...")
|
||||
saved = save_alerts_to_mysql(all_alerts)
|
||||
print(f"保存完成: {saved} 条")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"seq_len": 10,
|
||||
"stride": 2,
|
||||
"train_end_date": "2025-06-30",
|
||||
"val_end_date": "2025-09-30",
|
||||
"features": [
|
||||
"alpha_zscore",
|
||||
"amt_zscore",
|
||||
"rank_zscore",
|
||||
"momentum_3m",
|
||||
"momentum_5m",
|
||||
"limit_up_ratio"
|
||||
],
|
||||
"batch_size": 32768,
|
||||
"epochs": 150,
|
||||
"learning_rate": 0.0006,
|
||||
"weight_decay": 1e-05,
|
||||
"gradient_clip": 1.0,
|
||||
"patience": 15,
|
||||
"min_delta": 1e-06,
|
||||
"model": {
|
||||
"n_features": 6,
|
||||
"hidden_dim": 32,
|
||||
"latent_dim": 4,
|
||||
"num_layers": 1,
|
||||
"dropout": 0.2,
|
||||
"bidirectional": true
|
||||
},
|
||||
"clip_value": 5.0,
|
||||
"threshold_percentiles": [90, 95, 99]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"p90": 0.15,
|
||||
"p95": 0.25,
|
||||
"p99": 0.50,
|
||||
"mean": 0.08,
|
||||
"std": 0.12,
|
||||
"median": 0.06
|
||||
}
|
||||
635
ml/detector.py
@@ -1,635 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
概念异动检测器 - 融合版
|
||||
|
||||
结合两种方法的优势:
|
||||
1. 规则评分系统:可解释、稳定、覆盖已知模式
|
||||
2. LSTM Autoencoder:发现未知的异常模式
|
||||
|
||||
融合策略:
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 输入特征 │
|
||||
│ (alpha, alpha_delta, amt_ratio, amt_delta, rank_pct, │
|
||||
│ limit_up_ratio) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 规则评分系统 │ │ LSTM Autoencoder │ │
|
||||
│ │ (0-100分) │ │ (重构误差) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ rule_score (0-100) ml_score (标准化后 0-100) │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 融合策略 │
|
||||
│ │
|
||||
│ final_score = w1 * rule_score + w2 * ml_score │
|
||||
│ │
|
||||
│ 异动判定: │
|
||||
│ - rule_score >= 60 → 直接触发(规则强信号) │
|
||||
│ - ml_score >= 80 → 直接触发(ML强信号) │
|
||||
│ - final_score >= 50 → 融合触发 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
优势:
|
||||
- 规则系统保证已知模式的检出率
|
||||
- ML模型捕捉规则未覆盖的异常
|
||||
- 两者互相验证,减少误报
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
# 尝试导入模型(可能不存在)
|
||||
try:
|
||||
from model import LSTMAutoencoder, create_model
|
||||
HAS_MODEL = True
|
||||
except ImportError:
|
||||
HAS_MODEL = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnomalyResult:
|
||||
"""异动检测结果"""
|
||||
is_anomaly: bool
|
||||
final_score: float # 最终得分 (0-100)
|
||||
rule_score: float # 规则得分 (0-100)
|
||||
ml_score: float # ML得分 (0-100)
|
||||
trigger_reason: str # 触发原因
|
||||
rule_details: Dict # 规则明细
|
||||
anomaly_type: str # 异动类型: surge_up / surge_down / volume_spike / unknown
|
||||
|
||||
|
||||
class RuleBasedScorer:
|
||||
"""
|
||||
基于规则的评分系统
|
||||
|
||||
设计原则:
|
||||
- 每个规则独立打分
|
||||
- 分数可叠加
|
||||
- 阈值可配置
|
||||
"""
|
||||
|
||||
# 默认规则配置
|
||||
DEFAULT_RULES = {
|
||||
# Alpha 相关(超额收益)
|
||||
'alpha_strong': {
|
||||
'condition': lambda r: abs(r.get('alpha', 0)) >= 3.0,
|
||||
'score': 35,
|
||||
'description': 'Alpha强信号(|α|≥3%)'
|
||||
},
|
||||
'alpha_medium': {
|
||||
'condition': lambda r: 2.0 <= abs(r.get('alpha', 0)) < 3.0,
|
||||
'score': 25,
|
||||
'description': 'Alpha中等(2%≤|α|<3%)'
|
||||
},
|
||||
'alpha_weak': {
|
||||
'condition': lambda r: 1.5 <= abs(r.get('alpha', 0)) < 2.0,
|
||||
'score': 15,
|
||||
'description': 'Alpha轻微(1.5%≤|α|<2%)'
|
||||
},
|
||||
|
||||
# Alpha 变化率(加速度)
|
||||
'alpha_delta_strong': {
|
||||
'condition': lambda r: abs(r.get('alpha_delta', 0)) >= 1.0,
|
||||
'score': 30,
|
||||
'description': 'Alpha加速强(|Δα|≥1%)'
|
||||
},
|
||||
'alpha_delta_medium': {
|
||||
'condition': lambda r: 0.5 <= abs(r.get('alpha_delta', 0)) < 1.0,
|
||||
'score': 20,
|
||||
'description': 'Alpha加速中(0.5%≤|Δα|<1%)'
|
||||
},
|
||||
|
||||
# 成交额比率(放量)
|
||||
'volume_spike_strong': {
|
||||
'condition': lambda r: r.get('amt_ratio', 1) >= 5.0,
|
||||
'score': 30,
|
||||
'description': '极度放量(≥5倍)'
|
||||
},
|
||||
'volume_spike_medium': {
|
||||
'condition': lambda r: 3.0 <= r.get('amt_ratio', 1) < 5.0,
|
||||
'score': 20,
|
||||
'description': '显著放量(3-5倍)'
|
||||
},
|
||||
'volume_spike_weak': {
|
||||
'condition': lambda r: 2.0 <= r.get('amt_ratio', 1) < 3.0,
|
||||
'score': 10,
|
||||
'description': '轻微放量(2-3倍)'
|
||||
},
|
||||
|
||||
# 成交额变化率
|
||||
'amt_delta_strong': {
|
||||
'condition': lambda r: abs(r.get('amt_delta', 0)) >= 1.0,
|
||||
'score': 15,
|
||||
'description': '成交额急变(|Δamt|≥100%)'
|
||||
},
|
||||
|
||||
# 排名跳变
|
||||
'rank_top': {
|
||||
'condition': lambda r: r.get('rank_pct', 0.5) >= 0.95,
|
||||
'score': 25,
|
||||
'description': '排名前5%'
|
||||
},
|
||||
'rank_bottom': {
|
||||
'condition': lambda r: r.get('rank_pct', 0.5) <= 0.05,
|
||||
'score': 25,
|
||||
'description': '排名后5%'
|
||||
},
|
||||
'rank_high': {
|
||||
'condition': lambda r: 0.9 <= r.get('rank_pct', 0.5) < 0.95,
|
||||
'score': 15,
|
||||
'description': '排名前10%'
|
||||
},
|
||||
|
||||
# 涨停比例
|
||||
'limit_up_high': {
|
||||
'condition': lambda r: r.get('limit_up_ratio', 0) >= 0.2,
|
||||
'score': 25,
|
||||
'description': '涨停比例≥20%'
|
||||
},
|
||||
'limit_up_medium': {
|
||||
'condition': lambda r: 0.1 <= r.get('limit_up_ratio', 0) < 0.2,
|
||||
'score': 15,
|
||||
'description': '涨停比例10-20%'
|
||||
},
|
||||
|
||||
# 组合条件(更可靠的信号)
|
||||
'alpha_with_volume': {
|
||||
'condition': lambda r: abs(r.get('alpha', 0)) >= 1.5 and r.get('amt_ratio', 1) >= 2.0,
|
||||
'score': 20, # 额外加分
|
||||
'description': 'Alpha+放量组合'
|
||||
},
|
||||
'acceleration_with_rank': {
|
||||
'condition': lambda r: abs(r.get('alpha_delta', 0)) >= 0.5 and (r.get('rank_pct', 0.5) >= 0.9 or r.get('rank_pct', 0.5) <= 0.1),
|
||||
'score': 15, # 额外加分
|
||||
'description': '加速+排名异常组合'
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, rules: Dict = None):
|
||||
"""
|
||||
初始化规则评分器
|
||||
|
||||
Args:
|
||||
rules: 自定义规则,格式同 DEFAULT_RULES
|
||||
"""
|
||||
self.rules = rules or self.DEFAULT_RULES
|
||||
|
||||
def score(self, features: Dict) -> Tuple[float, Dict]:
|
||||
"""
|
||||
计算规则得分
|
||||
|
||||
Args:
|
||||
features: 特征字典,包含 alpha, alpha_delta, amt_ratio 等
|
||||
Returns:
|
||||
score: 总分 (0-100)
|
||||
details: 触发的规则明细
|
||||
"""
|
||||
total_score = 0
|
||||
triggered_rules = {}
|
||||
|
||||
for rule_name, rule_config in self.rules.items():
|
||||
try:
|
||||
if rule_config['condition'](features):
|
||||
total_score += rule_config['score']
|
||||
triggered_rules[rule_name] = {
|
||||
'score': rule_config['score'],
|
||||
'description': rule_config['description']
|
||||
}
|
||||
except Exception:
|
||||
# 忽略规则计算错误
|
||||
pass
|
||||
|
||||
# 限制在 0-100
|
||||
total_score = min(100, max(0, total_score))
|
||||
|
||||
return total_score, triggered_rules
|
||||
|
||||
def get_anomaly_type(self, features: Dict) -> str:
|
||||
"""判断异动类型"""
|
||||
alpha = features.get('alpha', 0)
|
||||
amt_ratio = features.get('amt_ratio', 1)
|
||||
|
||||
if alpha >= 1.5:
|
||||
return 'surge_up'
|
||||
elif alpha <= -1.5:
|
||||
return 'surge_down'
|
||||
elif amt_ratio >= 3.0:
|
||||
return 'volume_spike'
|
||||
else:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
class MLScorer:
|
||||
"""
|
||||
基于 LSTM Autoencoder 的评分器
|
||||
|
||||
将重构误差转换为 0-100 的分数
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
checkpoint_dir: str = 'ml/checkpoints',
|
||||
device: str = 'auto'
|
||||
):
|
||||
self.checkpoint_dir = Path(checkpoint_dir)
|
||||
|
||||
# 设备检测
|
||||
if device == 'auto':
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
elif device == 'cuda' and not torch.cuda.is_available():
|
||||
print("警告: CUDA 不可用,使用 CPU")
|
||||
self.device = torch.device('cpu')
|
||||
else:
|
||||
self.device = torch.device(device)
|
||||
|
||||
self.model = None
|
||||
self.thresholds = None
|
||||
self.config = None
|
||||
|
||||
# 尝试加载模型
|
||||
self._load_model()
|
||||
|
||||
def _load_model(self):
|
||||
"""加载模型和阈值"""
|
||||
if not HAS_MODEL:
|
||||
print("警告: 无法导入模型模块")
|
||||
return
|
||||
|
||||
model_path = self.checkpoint_dir / 'best_model.pt'
|
||||
thresholds_path = self.checkpoint_dir / 'thresholds.json'
|
||||
config_path = self.checkpoint_dir / 'config.json'
|
||||
|
||||
if not model_path.exists():
|
||||
print(f"警告: 模型文件不存在 {model_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
# 加载配置
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r') as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
# 先用 CPU 加载模型(避免 CUDA 不可用问题),再移动到目标设备
|
||||
checkpoint = torch.load(model_path, map_location='cpu')
|
||||
|
||||
model_config = self.config.get('model', {}) if self.config else {}
|
||||
self.model = create_model(model_config)
|
||||
self.model.load_state_dict(checkpoint['model_state_dict'])
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
# 加载阈值
|
||||
if thresholds_path.exists():
|
||||
with open(thresholds_path, 'r') as f:
|
||||
self.thresholds = json.load(f)
|
||||
|
||||
print(f"MLScorer 加载成功 (设备: {self.device})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"警告: 模型加载失败 - {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self.model = None
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""检查模型是否就绪"""
|
||||
return self.model is not None
|
||||
|
||||
@torch.no_grad()
|
||||
def score(self, sequence: np.ndarray) -> float:
|
||||
"""
|
||||
计算 ML 得分
|
||||
|
||||
Args:
|
||||
sequence: (seq_len, n_features) 或 (batch, seq_len, n_features)
|
||||
Returns:
|
||||
score: 0-100 的分数,越高越异常
|
||||
"""
|
||||
if not self.is_ready():
|
||||
return 0.0
|
||||
|
||||
# 确保是 3D
|
||||
if sequence.ndim == 2:
|
||||
sequence = sequence[np.newaxis, ...]
|
||||
|
||||
# 转为 tensor
|
||||
x = torch.FloatTensor(sequence).to(self.device)
|
||||
|
||||
# 计算重构误差
|
||||
output, _ = self.model(x)
|
||||
mse = ((output - x) ** 2).mean(dim=-1) # (batch, seq_len)
|
||||
|
||||
# 取最后时刻的误差
|
||||
error = mse[:, -1].cpu().numpy()
|
||||
|
||||
# 转换为 0-100 分数
|
||||
# 使用 p95 阈值作为参考
|
||||
if self.thresholds:
|
||||
p95 = self.thresholds.get('p95', 0.1)
|
||||
p99 = self.thresholds.get('p99', 0.2)
|
||||
else:
|
||||
p95, p99 = 0.1, 0.2
|
||||
|
||||
# 线性映射:p95 -> 50分, p99 -> 80分
|
||||
# error=0 -> 0分, error>=p99*1.5 -> 100分
|
||||
score = np.clip(error / p95 * 50, 0, 100)
|
||||
|
||||
return float(score[0]) if len(score) == 1 else score.tolist()
|
||||
|
||||
|
||||
class HybridAnomalyDetector:
|
||||
"""
|
||||
融合异动检测器
|
||||
|
||||
结合规则系统和 ML 模型
|
||||
"""
|
||||
|
||||
# 默认配置
|
||||
DEFAULT_CONFIG = {
|
||||
# 权重配置
|
||||
'rule_weight': 0.6, # 规则权重
|
||||
'ml_weight': 0.4, # ML权重
|
||||
|
||||
# 触发阈值
|
||||
'rule_trigger': 60, # 规则直接触发阈值
|
||||
'ml_trigger': 80, # ML直接触发阈值
|
||||
'fusion_trigger': 50, # 融合触发阈值
|
||||
|
||||
# 特征列表
|
||||
'features': [
|
||||
'alpha', 'alpha_delta', 'amt_ratio',
|
||||
'amt_delta', 'rank_pct', 'limit_up_ratio'
|
||||
],
|
||||
|
||||
# 序列长度(ML模型需要)
|
||||
'seq_len': 30,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict = None,
|
||||
checkpoint_dir: str = 'ml/checkpoints',
|
||||
device: str = 'auto'
|
||||
):
|
||||
self.config = {**self.DEFAULT_CONFIG, **(config or {})}
|
||||
|
||||
# 初始化评分器
|
||||
self.rule_scorer = RuleBasedScorer()
|
||||
self.ml_scorer = MLScorer(checkpoint_dir, device)
|
||||
|
||||
print(f"HybridAnomalyDetector 初始化完成")
|
||||
print(f" 规则权重: {self.config['rule_weight']}")
|
||||
print(f" ML权重: {self.config['ml_weight']}")
|
||||
print(f" ML模型: {'就绪' if self.ml_scorer.is_ready() else '未加载'}")
|
||||
|
||||
def detect(
|
||||
self,
|
||||
features: Dict,
|
||||
sequence: np.ndarray = None
|
||||
) -> AnomalyResult:
|
||||
"""
|
||||
检测异动
|
||||
|
||||
Args:
|
||||
features: 当前时刻的特征字典
|
||||
sequence: 历史序列 (seq_len, n_features),ML模型需要
|
||||
Returns:
|
||||
AnomalyResult: 检测结果
|
||||
"""
|
||||
# 1. 规则评分
|
||||
rule_score, rule_details = self.rule_scorer.score(features)
|
||||
|
||||
# 2. ML评分
|
||||
ml_score = 0.0
|
||||
if sequence is not None and self.ml_scorer.is_ready():
|
||||
ml_score = self.ml_scorer.score(sequence)
|
||||
|
||||
# 3. 融合得分
|
||||
w1 = self.config['rule_weight']
|
||||
w2 = self.config['ml_weight']
|
||||
|
||||
# 如果ML不可用,全部权重给规则
|
||||
if not self.ml_scorer.is_ready():
|
||||
w1, w2 = 1.0, 0.0
|
||||
|
||||
final_score = w1 * rule_score + w2 * ml_score
|
||||
|
||||
# 4. 判断是否异动
|
||||
is_anomaly = False
|
||||
trigger_reason = ''
|
||||
|
||||
if rule_score >= self.config['rule_trigger']:
|
||||
is_anomaly = True
|
||||
trigger_reason = f'规则强信号({rule_score:.0f}分)'
|
||||
elif ml_score >= self.config['ml_trigger']:
|
||||
is_anomaly = True
|
||||
trigger_reason = f'ML强信号({ml_score:.0f}分)'
|
||||
elif final_score >= self.config['fusion_trigger']:
|
||||
is_anomaly = True
|
||||
trigger_reason = f'融合触发({final_score:.0f}分)'
|
||||
|
||||
# 5. 判断异动类型
|
||||
anomaly_type = self.rule_scorer.get_anomaly_type(features) if is_anomaly else ''
|
||||
|
||||
return AnomalyResult(
|
||||
is_anomaly=is_anomaly,
|
||||
final_score=final_score,
|
||||
rule_score=rule_score,
|
||||
ml_score=ml_score,
|
||||
trigger_reason=trigger_reason,
|
||||
rule_details=rule_details,
|
||||
anomaly_type=anomaly_type
|
||||
)
|
||||
|
||||
def detect_batch(
|
||||
self,
|
||||
features_list: List[Dict],
|
||||
sequences: np.ndarray = None
|
||||
) -> List[AnomalyResult]:
|
||||
"""
|
||||
批量检测
|
||||
|
||||
Args:
|
||||
features_list: 特征字典列表
|
||||
sequences: (batch, seq_len, n_features)
|
||||
Returns:
|
||||
List[AnomalyResult]
|
||||
"""
|
||||
results = []
|
||||
|
||||
for i, features in enumerate(features_list):
|
||||
seq = sequences[i] if sequences is not None else None
|
||||
result = self.detect(features, seq)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ==================== 便捷函数 ====================
|
||||
|
||||
def create_detector(
|
||||
checkpoint_dir: str = 'ml/checkpoints',
|
||||
config: Dict = None
|
||||
) -> HybridAnomalyDetector:
|
||||
"""创建融合检测器"""
|
||||
return HybridAnomalyDetector(config, checkpoint_dir)
|
||||
|
||||
|
||||
def quick_detect(features: Dict) -> bool:
|
||||
"""
|
||||
快速检测(只用规则,不需要ML模型)
|
||||
|
||||
适用于:
|
||||
- 实时检测
|
||||
- ML模型未训练完成时
|
||||
"""
|
||||
scorer = RuleBasedScorer()
|
||||
score, _ = scorer.score(features)
|
||||
return score >= 50
|
||||
|
||||
|
||||
# ==================== 测试 ====================
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("融合异动检测器测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 创建检测器
|
||||
detector = create_detector()
|
||||
|
||||
# 测试用例
|
||||
test_cases = [
|
||||
{
|
||||
'name': '正常情况',
|
||||
'features': {
|
||||
'alpha': 0.5,
|
||||
'alpha_delta': 0.1,
|
||||
'amt_ratio': 1.2,
|
||||
'amt_delta': 0.1,
|
||||
'rank_pct': 0.5,
|
||||
'limit_up_ratio': 0.02
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'Alpha异动',
|
||||
'features': {
|
||||
'alpha': 3.5,
|
||||
'alpha_delta': 0.8,
|
||||
'amt_ratio': 2.5,
|
||||
'amt_delta': 0.5,
|
||||
'rank_pct': 0.92,
|
||||
'limit_up_ratio': 0.05
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': '放量异动',
|
||||
'features': {
|
||||
'alpha': 1.2,
|
||||
'alpha_delta': 0.3,
|
||||
'amt_ratio': 6.0,
|
||||
'amt_delta': 1.5,
|
||||
'rank_pct': 0.85,
|
||||
'limit_up_ratio': 0.08
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': '涨停潮',
|
||||
'features': {
|
||||
'alpha': 2.5,
|
||||
'alpha_delta': 0.6,
|
||||
'amt_ratio': 3.5,
|
||||
'amt_delta': 0.8,
|
||||
'rank_pct': 0.98,
|
||||
'limit_up_ratio': 0.25
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
print("\n" + "-" * 60)
|
||||
print("测试1: 只用规则(无序列数据)")
|
||||
print("-" * 60)
|
||||
|
||||
for case in test_cases:
|
||||
result = detector.detect(case['features'])
|
||||
|
||||
print(f"\n{case['name']}:")
|
||||
print(f" 异动: {'是' if result.is_anomaly else '否'}")
|
||||
print(f" 最终得分: {result.final_score:.1f}")
|
||||
print(f" 规则得分: {result.rule_score:.1f}")
|
||||
print(f" ML得分: {result.ml_score:.1f}")
|
||||
if result.is_anomaly:
|
||||
print(f" 触发原因: {result.trigger_reason}")
|
||||
print(f" 异动类型: {result.anomaly_type}")
|
||||
print(f" 触发规则: {list(result.rule_details.keys())}")
|
||||
|
||||
# 测试2: 带序列数据的融合检测
|
||||
print("\n" + "-" * 60)
|
||||
print("测试2: 融合检测(规则 + ML)")
|
||||
print("-" * 60)
|
||||
|
||||
# 生成模拟序列数据
|
||||
seq_len = 30
|
||||
n_features = 6
|
||||
|
||||
# 正常序列:小幅波动
|
||||
normal_sequence = np.random.randn(seq_len, n_features) * 0.3
|
||||
normal_sequence[:, 0] = np.linspace(0, 0.5, seq_len) # alpha 缓慢上升
|
||||
normal_sequence[:, 2] = np.abs(normal_sequence[:, 2]) + 1 # amt_ratio > 0
|
||||
|
||||
# 异常序列:最后几个时间步突然变化
|
||||
anomaly_sequence = np.random.randn(seq_len, n_features) * 0.3
|
||||
anomaly_sequence[-5:, 0] = np.linspace(1, 4, 5) # alpha 突然飙升
|
||||
anomaly_sequence[-5:, 1] = np.linspace(0.2, 1.5, 5) # alpha_delta 加速
|
||||
anomaly_sequence[-5:, 2] = np.linspace(2, 6, 5) # amt_ratio 放量
|
||||
anomaly_sequence[:, 2] = np.abs(anomaly_sequence[:, 2]) + 1
|
||||
|
||||
# 测试正常序列
|
||||
normal_features = {
|
||||
'alpha': float(normal_sequence[-1, 0]),
|
||||
'alpha_delta': float(normal_sequence[-1, 1]),
|
||||
'amt_ratio': float(normal_sequence[-1, 2]),
|
||||
'amt_delta': float(normal_sequence[-1, 3]),
|
||||
'rank_pct': 0.5,
|
||||
'limit_up_ratio': 0.02
|
||||
}
|
||||
|
||||
result = detector.detect(normal_features, normal_sequence)
|
||||
print(f"\n正常序列:")
|
||||
print(f" 异动: {'是' if result.is_anomaly else '否'}")
|
||||
print(f" 最终得分: {result.final_score:.1f}")
|
||||
print(f" 规则得分: {result.rule_score:.1f}")
|
||||
print(f" ML得分: {result.ml_score:.1f}")
|
||||
|
||||
# 测试异常序列
|
||||
anomaly_features = {
|
||||
'alpha': float(anomaly_sequence[-1, 0]),
|
||||
'alpha_delta': float(anomaly_sequence[-1, 1]),
|
||||
'amt_ratio': float(anomaly_sequence[-1, 2]),
|
||||
'amt_delta': float(anomaly_sequence[-1, 3]),
|
||||
'rank_pct': 0.95,
|
||||
'limit_up_ratio': 0.15
|
||||
}
|
||||
|
||||
result = detector.detect(anomaly_features, anomaly_sequence)
|
||||
print(f"\n异常序列:")
|
||||
print(f" 异动: {'是' if result.is_anomaly else '否'}")
|
||||
print(f" 最终得分: {result.final_score:.1f}")
|
||||
print(f" 规则得分: {result.rule_score:.1f}")
|
||||
print(f" ML得分: {result.ml_score:.1f}")
|
||||
if result.is_anomaly:
|
||||
print(f" 触发原因: {result.trigger_reason}")
|
||||
print(f" 异动类型: {result.anomaly_type}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成!")
|
||||
@@ -1,716 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
异动检测器 V2 - 基于时间片对齐 + 持续性确认
|
||||
|
||||
核心改进:
|
||||
1. Z-Score 特征:相对于同时间片历史的偏离
|
||||
2. 短序列 LSTM:10分钟序列,开盘即可用
|
||||
3. 持续性确认:5分钟窗口内60%时刻超标才确认为异动
|
||||
|
||||
检测流程:
|
||||
1. 计算当前时刻的 Z-Score(对比同时间片历史基线)
|
||||
2. 构建最近10分钟的 Z-Score 序列
|
||||
3. LSTM 计算重构误差(ML分数)
|
||||
4. 规则评分(基于 Z-Score 的规则)
|
||||
5. 滑动窗口确认:最近5分钟内是否有足够多的时刻超标
|
||||
6. 只有通过持续性确认的才输出为异动
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pickle
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from collections import defaultdict, deque
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
from sqlalchemy import create_engine, text
|
||||
from elasticsearch import Elasticsearch
|
||||
from clickhouse_driver import Client
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from ml.model import TransformerAutoencoder
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
MYSQL_ENGINE = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
|
||||
echo=False
|
||||
)
|
||||
|
||||
ES_CLIENT = Elasticsearch(['http://127.0.0.1:9200'])
|
||||
ES_INDEX = 'concept_library_v3'
|
||||
|
||||
CLICKHOUSE_CONFIG = {
|
||||
'host': '127.0.0.1',
|
||||
'port': 9000,
|
||||
'user': 'default',
|
||||
'password': 'Zzl33818!',
|
||||
'database': 'stock'
|
||||
}
|
||||
|
||||
REFERENCE_INDEX = '000001.SH'
|
||||
|
||||
# 检测配置
|
||||
CONFIG = {
|
||||
# 序列配置
|
||||
'seq_len': 10, # LSTM 序列长度(分钟)
|
||||
|
||||
# 持续性确认配置(核心!)
|
||||
'confirm_window': 5, # 确认窗口(分钟)
|
||||
'confirm_ratio': 0.6, # 确认比例(60%时刻需要超标)
|
||||
|
||||
# Z-Score 阈值
|
||||
'alpha_zscore_threshold': 2.0, # Alpha Z-Score 阈值
|
||||
'amt_zscore_threshold': 2.5, # 成交额 Z-Score 阈值
|
||||
|
||||
# 融合权重
|
||||
'rule_weight': 0.5,
|
||||
'ml_weight': 0.5,
|
||||
|
||||
# 触发阈值
|
||||
'rule_trigger': 60,
|
||||
'ml_trigger': 70,
|
||||
'fusion_trigger': 50,
|
||||
|
||||
# 冷却期
|
||||
'cooldown_minutes': 10,
|
||||
'max_alerts_per_minute': 15,
|
||||
|
||||
# Z-Score 截断
|
||||
'zscore_clip': 5.0,
|
||||
}
|
||||
|
||||
# V2 特征列表
|
||||
FEATURES_V2 = [
|
||||
'alpha_zscore', 'amt_zscore', 'rank_zscore',
|
||||
'momentum_3m', 'momentum_5m', 'limit_up_ratio'
|
||||
]
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def get_ch_client():
|
||||
return Client(**CLICKHOUSE_CONFIG)
|
||||
|
||||
|
||||
def code_to_ch_format(code: str) -> str:
|
||||
if not code or len(code) != 6 or not code.isdigit():
|
||||
return None
|
||||
if code.startswith('6'):
|
||||
return f"{code}.SH"
|
||||
elif code.startswith('0') or code.startswith('3'):
|
||||
return f"{code}.SZ"
|
||||
else:
|
||||
return f"{code}.BJ"
|
||||
|
||||
|
||||
def time_to_slot(ts) -> str:
|
||||
"""时间戳转时间片(HH:MM)"""
|
||||
if isinstance(ts, str):
|
||||
return ts
|
||||
return ts.strftime('%H:%M')
|
||||
|
||||
|
||||
# ==================== 基线加载 ====================
|
||||
|
||||
def load_baselines(baseline_dir: str = 'ml/data_v2/baselines') -> Dict[str, pd.DataFrame]:
|
||||
"""加载时间片基线"""
|
||||
baseline_file = os.path.join(baseline_dir, 'baselines.pkl')
|
||||
if os.path.exists(baseline_file):
|
||||
with open(baseline_file, 'rb') as f:
|
||||
return pickle.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
# ==================== 规则评分(基于 Z-Score)====================
|
||||
|
||||
def score_rules_zscore(row: Dict) -> Tuple[float, List[str]]:
|
||||
"""
|
||||
基于 Z-Score 的规则评分
|
||||
|
||||
设计思路:Z-Score 已经标准化,直接用阈值判断
|
||||
"""
|
||||
score = 0.0
|
||||
triggered = []
|
||||
|
||||
alpha_zscore = row.get('alpha_zscore', 0)
|
||||
amt_zscore = row.get('amt_zscore', 0)
|
||||
rank_zscore = row.get('rank_zscore', 0)
|
||||
momentum_3m = row.get('momentum_3m', 0)
|
||||
momentum_5m = row.get('momentum_5m', 0)
|
||||
limit_up_ratio = row.get('limit_up_ratio', 0)
|
||||
|
||||
alpha_zscore_abs = abs(alpha_zscore)
|
||||
amt_zscore_abs = abs(amt_zscore)
|
||||
|
||||
# ========== Alpha Z-Score 规则 ==========
|
||||
if alpha_zscore_abs >= 4.0:
|
||||
score += 25
|
||||
triggered.append('alpha_zscore_extreme')
|
||||
elif alpha_zscore_abs >= 3.0:
|
||||
score += 18
|
||||
triggered.append('alpha_zscore_strong')
|
||||
elif alpha_zscore_abs >= 2.0:
|
||||
score += 10
|
||||
triggered.append('alpha_zscore_moderate')
|
||||
|
||||
# ========== 成交额 Z-Score 规则 ==========
|
||||
if amt_zscore >= 4.0:
|
||||
score += 20
|
||||
triggered.append('amt_zscore_extreme')
|
||||
elif amt_zscore >= 3.0:
|
||||
score += 12
|
||||
triggered.append('amt_zscore_strong')
|
||||
elif amt_zscore >= 2.0:
|
||||
score += 6
|
||||
triggered.append('amt_zscore_moderate')
|
||||
|
||||
# ========== 排名 Z-Score 规则 ==========
|
||||
if abs(rank_zscore) >= 3.0:
|
||||
score += 15
|
||||
triggered.append('rank_zscore_extreme')
|
||||
elif abs(rank_zscore) >= 2.0:
|
||||
score += 8
|
||||
triggered.append('rank_zscore_strong')
|
||||
|
||||
# ========== 动量规则 ==========
|
||||
if momentum_3m >= 1.0:
|
||||
score += 12
|
||||
triggered.append('momentum_3m_strong')
|
||||
elif momentum_3m >= 0.5:
|
||||
score += 6
|
||||
triggered.append('momentum_3m_moderate')
|
||||
|
||||
if momentum_5m >= 1.5:
|
||||
score += 10
|
||||
triggered.append('momentum_5m_strong')
|
||||
|
||||
# ========== 涨停比例规则 ==========
|
||||
if limit_up_ratio >= 0.3:
|
||||
score += 20
|
||||
triggered.append('limit_up_extreme')
|
||||
elif limit_up_ratio >= 0.15:
|
||||
score += 12
|
||||
triggered.append('limit_up_strong')
|
||||
elif limit_up_ratio >= 0.08:
|
||||
score += 5
|
||||
triggered.append('limit_up_moderate')
|
||||
|
||||
# ========== 组合规则 ==========
|
||||
# Alpha Z-Score + 成交额放大
|
||||
if alpha_zscore_abs >= 2.0 and amt_zscore >= 2.0:
|
||||
score += 15
|
||||
triggered.append('combo_alpha_amt')
|
||||
|
||||
# Alpha Z-Score + 涨停
|
||||
if alpha_zscore_abs >= 2.0 and limit_up_ratio >= 0.1:
|
||||
score += 12
|
||||
triggered.append('combo_alpha_limitup')
|
||||
|
||||
return min(score, 100), triggered
|
||||
|
||||
|
||||
# ==================== ML 评分器 ====================
|
||||
|
||||
class MLScorerV2:
|
||||
"""V2 ML 评分器"""
|
||||
|
||||
def __init__(self, model_dir: str = 'ml/checkpoints_v2'):
|
||||
self.model_dir = model_dir
|
||||
self.model = None
|
||||
self.thresholds = None
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
self._load_model()
|
||||
|
||||
def _load_model(self):
|
||||
"""加载模型和阈值"""
|
||||
model_path = os.path.join(self.model_dir, 'best_model.pt')
|
||||
threshold_path = os.path.join(self.model_dir, 'thresholds.json')
|
||||
config_path = os.path.join(self.model_dir, 'config.json')
|
||||
|
||||
if not os.path.exists(model_path):
|
||||
print(f"警告: 模型文件不存在: {model_path}")
|
||||
return
|
||||
|
||||
# 加载配置
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# 创建模型
|
||||
model_config = config.get('model', {})
|
||||
self.model = TransformerAutoencoder(**model_config)
|
||||
|
||||
# 加载权重
|
||||
checkpoint = torch.load(model_path, map_location=self.device)
|
||||
self.model.load_state_dict(checkpoint['model_state_dict'])
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
# 加载阈值
|
||||
if os.path.exists(threshold_path):
|
||||
with open(threshold_path, 'r') as f:
|
||||
self.thresholds = json.load(f)
|
||||
|
||||
print(f"V2 模型加载完成: {model_path}")
|
||||
|
||||
@torch.no_grad()
|
||||
def score_batch(self, sequences: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
批量计算 ML 分数
|
||||
|
||||
返回 0-100 的分数,越高越异常
|
||||
"""
|
||||
if self.model is None:
|
||||
return np.zeros(len(sequences))
|
||||
|
||||
# 转换为 tensor
|
||||
x = torch.FloatTensor(sequences).to(self.device)
|
||||
|
||||
# 计算重构误差
|
||||
errors = self.model.compute_reconstruction_error(x, reduction='none')
|
||||
# 取最后一个时刻的误差
|
||||
last_errors = errors[:, -1].cpu().numpy()
|
||||
|
||||
# 转换为 0-100 分数
|
||||
if self.thresholds:
|
||||
p50 = self.thresholds.get('median', 0.1)
|
||||
p99 = self.thresholds.get('p99', 1.0)
|
||||
|
||||
# 线性映射:p50 -> 50分,p99 -> 99分
|
||||
scores = 50 + (last_errors - p50) / (p99 - p50) * 49
|
||||
scores = np.clip(scores, 0, 100)
|
||||
else:
|
||||
# 没有阈值时,简单归一化
|
||||
scores = last_errors * 100
|
||||
scores = np.clip(scores, 0, 100)
|
||||
|
||||
return scores
|
||||
|
||||
|
||||
# ==================== 实时数据管理器 ====================
|
||||
|
||||
class RealtimeDataManagerV2:
|
||||
"""
|
||||
V2 实时数据管理器
|
||||
|
||||
维护:
|
||||
1. 每个概念的历史 Z-Score 序列(用于 LSTM 输入)
|
||||
2. 每个概念的异动候选队列(用于持续性确认)
|
||||
"""
|
||||
|
||||
def __init__(self, concepts: List[dict], baselines: Dict[str, pd.DataFrame]):
|
||||
self.concepts = {c['concept_id']: c for c in concepts}
|
||||
self.baselines = baselines
|
||||
|
||||
# 概念到股票的映射
|
||||
self.concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts}
|
||||
|
||||
# 历史 Z-Score 序列(每个概念)
|
||||
# {concept_id: deque([(timestamp, features_dict), ...], maxlen=seq_len)}
|
||||
self.zscore_history = defaultdict(lambda: deque(maxlen=CONFIG['seq_len']))
|
||||
|
||||
# 异动候选队列(用于持续性确认)
|
||||
# {concept_id: deque([(timestamp, score), ...], maxlen=confirm_window)}
|
||||
self.anomaly_candidates = defaultdict(lambda: deque(maxlen=CONFIG['confirm_window']))
|
||||
|
||||
# 冷却期记录
|
||||
self.cooldown = {}
|
||||
|
||||
# 上一次更新的时间戳
|
||||
self.last_timestamp = None
|
||||
|
||||
def compute_zscore_features(
|
||||
self,
|
||||
concept_id: str,
|
||||
timestamp,
|
||||
alpha: float,
|
||||
total_amt: float,
|
||||
rank_pct: float,
|
||||
limit_up_ratio: float
|
||||
) -> Optional[Dict]:
|
||||
"""计算单个概念单个时刻的 Z-Score 特征"""
|
||||
if concept_id not in self.baselines:
|
||||
return None
|
||||
|
||||
baseline = self.baselines[concept_id]
|
||||
time_slot = time_to_slot(timestamp)
|
||||
|
||||
# 查找对应时间片的基线
|
||||
bl_row = baseline[baseline['time_slot'] == time_slot]
|
||||
if bl_row.empty:
|
||||
return None
|
||||
|
||||
bl = bl_row.iloc[0]
|
||||
|
||||
# 检查样本量
|
||||
if bl.get('sample_count', 0) < 10:
|
||||
return None
|
||||
|
||||
# 计算 Z-Score
|
||||
alpha_zscore = (alpha - bl['alpha_mean']) / bl['alpha_std']
|
||||
amt_zscore = (total_amt - bl['amt_mean']) / bl['amt_std']
|
||||
rank_zscore = (rank_pct - bl['rank_mean']) / bl['rank_std']
|
||||
|
||||
# 截断
|
||||
clip = CONFIG['zscore_clip']
|
||||
alpha_zscore = np.clip(alpha_zscore, -clip, clip)
|
||||
amt_zscore = np.clip(amt_zscore, -clip, clip)
|
||||
rank_zscore = np.clip(rank_zscore, -clip, clip)
|
||||
|
||||
# 计算动量(需要历史)
|
||||
history = self.zscore_history[concept_id]
|
||||
momentum_3m = 0
|
||||
momentum_5m = 0
|
||||
|
||||
if len(history) >= 3:
|
||||
recent_alphas = [h[1]['alpha'] for h in list(history)[-3:]]
|
||||
older_alphas = [h[1]['alpha'] for h in list(history)[-6:-3]] if len(history) >= 6 else [alpha]
|
||||
momentum_3m = np.mean(recent_alphas) - np.mean(older_alphas)
|
||||
|
||||
if len(history) >= 5:
|
||||
recent_alphas = [h[1]['alpha'] for h in list(history)[-5:]]
|
||||
older_alphas = [h[1]['alpha'] for h in list(history)[-10:-5]] if len(history) >= 10 else [alpha]
|
||||
momentum_5m = np.mean(recent_alphas) - np.mean(older_alphas)
|
||||
|
||||
return {
|
||||
'alpha': alpha,
|
||||
'alpha_zscore': alpha_zscore,
|
||||
'amt_zscore': amt_zscore,
|
||||
'rank_zscore': rank_zscore,
|
||||
'momentum_3m': momentum_3m,
|
||||
'momentum_5m': momentum_5m,
|
||||
'limit_up_ratio': limit_up_ratio,
|
||||
'total_amt': total_amt,
|
||||
'rank_pct': rank_pct,
|
||||
}
|
||||
|
||||
def update(self, concept_id: str, timestamp, features: Dict):
|
||||
"""更新概念的历史数据"""
|
||||
self.zscore_history[concept_id].append((timestamp, features))
|
||||
|
||||
def get_sequence(self, concept_id: str) -> Optional[np.ndarray]:
|
||||
"""获取用于 LSTM 的序列"""
|
||||
history = self.zscore_history[concept_id]
|
||||
|
||||
if len(history) < CONFIG['seq_len']:
|
||||
return None
|
||||
|
||||
# 提取特征
|
||||
feature_list = []
|
||||
for _, features in history:
|
||||
feature_list.append([
|
||||
features['alpha_zscore'],
|
||||
features['amt_zscore'],
|
||||
features['rank_zscore'],
|
||||
features['momentum_3m'],
|
||||
features['momentum_5m'],
|
||||
features['limit_up_ratio'],
|
||||
])
|
||||
|
||||
return np.array(feature_list)
|
||||
|
||||
def add_anomaly_candidate(self, concept_id: str, timestamp, score: float):
|
||||
"""添加异动候选"""
|
||||
self.anomaly_candidates[concept_id].append((timestamp, score))
|
||||
|
||||
def check_sustained_anomaly(self, concept_id: str, threshold: float) -> Tuple[bool, float]:
|
||||
"""
|
||||
检查是否为持续性异动
|
||||
|
||||
返回:(是否确认, 确认比例)
|
||||
"""
|
||||
candidates = self.anomaly_candidates[concept_id]
|
||||
|
||||
if len(candidates) < CONFIG['confirm_window']:
|
||||
return False, 0.0
|
||||
|
||||
# 统计超过阈值的时刻数量
|
||||
exceed_count = sum(1 for _, score in candidates if score >= threshold)
|
||||
ratio = exceed_count / len(candidates)
|
||||
|
||||
return ratio >= CONFIG['confirm_ratio'], ratio
|
||||
|
||||
def check_cooldown(self, concept_id: str, timestamp) -> bool:
|
||||
"""检查是否在冷却期"""
|
||||
if concept_id not in self.cooldown:
|
||||
return False
|
||||
|
||||
last_alert = self.cooldown[concept_id]
|
||||
try:
|
||||
diff = (timestamp - last_alert).total_seconds() / 60
|
||||
return diff < CONFIG['cooldown_minutes']
|
||||
except:
|
||||
return False
|
||||
|
||||
def set_cooldown(self, concept_id: str, timestamp):
|
||||
"""设置冷却期"""
|
||||
self.cooldown[concept_id] = timestamp
|
||||
|
||||
|
||||
# ==================== 异动检测器 V2 ====================
|
||||
|
||||
class AnomalyDetectorV2:
|
||||
"""
|
||||
V2 异动检测器
|
||||
|
||||
核心流程:
|
||||
1. 获取实时数据
|
||||
2. 计算 Z-Score 特征
|
||||
3. 规则评分 + ML 评分
|
||||
4. 持续性确认
|
||||
5. 输出异动
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_dir: str = 'ml/checkpoints_v2',
|
||||
baseline_dir: str = 'ml/data_v2/baselines'
|
||||
):
|
||||
# 加载概念
|
||||
self.concepts = self._load_concepts()
|
||||
|
||||
# 加载基线
|
||||
self.baselines = load_baselines(baseline_dir)
|
||||
print(f"加载了 {len(self.baselines)} 个概念的基线")
|
||||
|
||||
# 初始化 ML 评分器
|
||||
self.ml_scorer = MLScorerV2(model_dir)
|
||||
|
||||
# 初始化数据管理器
|
||||
self.data_manager = RealtimeDataManagerV2(self.concepts, self.baselines)
|
||||
|
||||
# 收集所有股票
|
||||
self.all_stocks = list(set(s for c in self.concepts for s in c['stocks']))
|
||||
|
||||
def _load_concepts(self) -> List[dict]:
|
||||
"""从 ES 加载概念"""
|
||||
concepts = []
|
||||
query = {"query": {"match_all": {}}, "size": 100, "_source": ["concept_id", "concept", "stocks"]}
|
||||
|
||||
resp = ES_CLIENT.search(index=ES_INDEX, body=query, scroll='2m')
|
||||
scroll_id = resp['_scroll_id']
|
||||
hits = resp['hits']['hits']
|
||||
|
||||
while len(hits) > 0:
|
||||
for hit in hits:
|
||||
source = hit['_source']
|
||||
stocks = []
|
||||
if 'stocks' in source and isinstance(source['stocks'], list):
|
||||
for stock in source['stocks']:
|
||||
if isinstance(stock, dict) and 'code' in stock and stock['code']:
|
||||
stocks.append(stock['code'])
|
||||
if stocks:
|
||||
concepts.append({
|
||||
'concept_id': source.get('concept_id'),
|
||||
'concept_name': source.get('concept'),
|
||||
'stocks': stocks
|
||||
})
|
||||
|
||||
resp = ES_CLIENT.scroll(scroll_id=scroll_id, scroll='2m')
|
||||
scroll_id = resp['_scroll_id']
|
||||
hits = resp['hits']['hits']
|
||||
|
||||
ES_CLIENT.clear_scroll(scroll_id=scroll_id)
|
||||
print(f"加载了 {len(concepts)} 个概念")
|
||||
return concepts
|
||||
|
||||
def detect(self, trade_date: str) -> List[Dict]:
|
||||
"""
|
||||
检测指定日期的异动
|
||||
|
||||
返回异动列表
|
||||
"""
|
||||
print(f"\n检测 {trade_date} 的异动...")
|
||||
|
||||
# 获取原始数据
|
||||
raw_features = self._compute_raw_features(trade_date)
|
||||
if raw_features.empty:
|
||||
print("无数据")
|
||||
return []
|
||||
|
||||
# 按时间排序
|
||||
timestamps = sorted(raw_features['timestamp'].unique())
|
||||
print(f"时间点数: {len(timestamps)}")
|
||||
|
||||
all_alerts = []
|
||||
|
||||
for ts in timestamps:
|
||||
ts_data = raw_features[raw_features['timestamp'] == ts]
|
||||
ts_alerts = self._process_timestamp(ts, ts_data, trade_date)
|
||||
all_alerts.extend(ts_alerts)
|
||||
|
||||
print(f"共检测到 {len(all_alerts)} 个异动")
|
||||
return all_alerts
|
||||
|
||||
def _compute_raw_features(self, trade_date: str) -> pd.DataFrame:
|
||||
"""计算原始特征(同 prepare_data_v2)"""
|
||||
# 这里简化处理,直接调用数据准备逻辑
|
||||
from prepare_data_v2 import compute_raw_concept_features
|
||||
return compute_raw_concept_features(trade_date, self.concepts, self.all_stocks)
|
||||
|
||||
def _process_timestamp(self, timestamp, ts_data: pd.DataFrame, trade_date: str) -> List[Dict]:
|
||||
"""处理单个时间戳"""
|
||||
alerts = []
|
||||
candidates = [] # (concept_id, features, rule_score, triggered_rules)
|
||||
|
||||
for _, row in ts_data.iterrows():
|
||||
concept_id = row['concept_id']
|
||||
|
||||
# 计算 Z-Score 特征
|
||||
features = self.data_manager.compute_zscore_features(
|
||||
concept_id, timestamp,
|
||||
row['alpha'], row['total_amt'], row['rank_pct'], row['limit_up_ratio']
|
||||
)
|
||||
|
||||
if features is None:
|
||||
continue
|
||||
|
||||
# 更新历史
|
||||
self.data_manager.update(concept_id, timestamp, features)
|
||||
|
||||
# 规则评分
|
||||
rule_score, triggered_rules = score_rules_zscore(features)
|
||||
|
||||
# 收集候选
|
||||
candidates.append((concept_id, features, rule_score, triggered_rules))
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# 批量 ML 评分
|
||||
sequences = []
|
||||
valid_candidates = []
|
||||
|
||||
for concept_id, features, rule_score, triggered_rules in candidates:
|
||||
seq = self.data_manager.get_sequence(concept_id)
|
||||
if seq is not None:
|
||||
sequences.append(seq)
|
||||
valid_candidates.append((concept_id, features, rule_score, triggered_rules))
|
||||
|
||||
if not sequences:
|
||||
return []
|
||||
|
||||
sequences = np.array(sequences)
|
||||
ml_scores = self.ml_scorer.score_batch(sequences)
|
||||
|
||||
# 融合评分 + 持续性确认
|
||||
for i, (concept_id, features, rule_score, triggered_rules) in enumerate(valid_candidates):
|
||||
ml_score = ml_scores[i]
|
||||
final_score = CONFIG['rule_weight'] * rule_score + CONFIG['ml_weight'] * ml_score
|
||||
|
||||
# 判断是否触发
|
||||
is_triggered = (
|
||||
rule_score >= CONFIG['rule_trigger'] or
|
||||
ml_score >= CONFIG['ml_trigger'] or
|
||||
final_score >= CONFIG['fusion_trigger']
|
||||
)
|
||||
|
||||
# 添加到候选队列
|
||||
self.data_manager.add_anomaly_candidate(concept_id, timestamp, final_score)
|
||||
|
||||
if not is_triggered:
|
||||
continue
|
||||
|
||||
# 检查冷却期
|
||||
if self.data_manager.check_cooldown(concept_id, timestamp):
|
||||
continue
|
||||
|
||||
# 持续性确认
|
||||
is_sustained, confirm_ratio = self.data_manager.check_sustained_anomaly(
|
||||
concept_id, CONFIG['fusion_trigger']
|
||||
)
|
||||
|
||||
if not is_sustained:
|
||||
continue
|
||||
|
||||
# 确认为异动!
|
||||
self.data_manager.set_cooldown(concept_id, timestamp)
|
||||
|
||||
# 确定异动类型
|
||||
alpha = features['alpha']
|
||||
if alpha >= 1.5:
|
||||
alert_type = 'surge_up'
|
||||
elif alpha <= -1.5:
|
||||
alert_type = 'surge_down'
|
||||
elif features['amt_zscore'] >= 3.0:
|
||||
alert_type = 'volume_spike'
|
||||
else:
|
||||
alert_type = 'surge'
|
||||
|
||||
# 确定触发原因
|
||||
if rule_score >= CONFIG['rule_trigger']:
|
||||
trigger_reason = f'规则({rule_score:.0f})+持续确认({confirm_ratio:.0%})'
|
||||
elif ml_score >= CONFIG['ml_trigger']:
|
||||
trigger_reason = f'ML({ml_score:.0f})+持续确认({confirm_ratio:.0%})'
|
||||
else:
|
||||
trigger_reason = f'融合({final_score:.0f})+持续确认({confirm_ratio:.0%})'
|
||||
|
||||
alerts.append({
|
||||
'concept_id': concept_id,
|
||||
'concept_name': self.data_manager.concepts.get(concept_id, {}).get('concept_name', concept_id),
|
||||
'alert_time': timestamp,
|
||||
'trade_date': trade_date,
|
||||
'alert_type': alert_type,
|
||||
'final_score': final_score,
|
||||
'rule_score': rule_score,
|
||||
'ml_score': ml_score,
|
||||
'trigger_reason': trigger_reason,
|
||||
'confirm_ratio': confirm_ratio,
|
||||
'alpha': alpha,
|
||||
'alpha_zscore': features['alpha_zscore'],
|
||||
'amt_zscore': features['amt_zscore'],
|
||||
'rank_zscore': features['rank_zscore'],
|
||||
'momentum_3m': features['momentum_3m'],
|
||||
'momentum_5m': features['momentum_5m'],
|
||||
'limit_up_ratio': features['limit_up_ratio'],
|
||||
'triggered_rules': triggered_rules,
|
||||
})
|
||||
|
||||
# 每分钟最多 N 个
|
||||
if len(alerts) > CONFIG['max_alerts_per_minute']:
|
||||
alerts = sorted(alerts, key=lambda x: x['final_score'], reverse=True)
|
||||
alerts = alerts[:CONFIG['max_alerts_per_minute']]
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='V2 异动检测器')
|
||||
parser.add_argument('--date', type=str, default=None, help='检测日期(默认今天)')
|
||||
parser.add_argument('--model_dir', type=str, default='ml/checkpoints_v2')
|
||||
parser.add_argument('--baseline_dir', type=str, default='ml/data_v2/baselines')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
trade_date = args.date or datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
detector = AnomalyDetectorV2(
|
||||
model_dir=args.model_dir,
|
||||
baseline_dir=args.baseline_dir
|
||||
)
|
||||
|
||||
alerts = detector.detect(trade_date)
|
||||
|
||||
print(f"\n检测结果:")
|
||||
for alert in alerts[:20]:
|
||||
print(f" [{alert['alert_time'].strftime('%H:%M') if hasattr(alert['alert_time'], 'strftime') else alert['alert_time']}] "
|
||||
f"{alert['concept_name']} ({alert['alert_type']}) "
|
||||
f"分数={alert['final_score']:.0f} "
|
||||
f"确认率={alert['confirm_ratio']:.0%}")
|
||||
|
||||
if len(alerts) > 20:
|
||||
print(f" ... 共 {len(alerts)} 个异动")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,526 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
增强版概念异动检测器
|
||||
|
||||
融合两种检测方法:
|
||||
1. Alpha-based Z-Score(规则方法,实时性好)
|
||||
2. Transformer Autoencoder(ML方法,更准确)
|
||||
|
||||
使用策略:
|
||||
- 当 ML 模型可用且历史数据足够时,优先使用 ML 方法
|
||||
- 否则回退到 Alpha-based 方法
|
||||
- 可以配置两种方法的融合权重
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from collections import deque
|
||||
import numpy as np
|
||||
|
||||
# 添加父目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
ENHANCED_CONFIG = {
|
||||
# 融合策略
|
||||
'fusion_mode': 'adaptive', # 'ml_only', 'alpha_only', 'adaptive', 'ensemble'
|
||||
|
||||
# ML 权重(在 ensemble 模式下)
|
||||
'ml_weight': 0.6,
|
||||
'alpha_weight': 0.4,
|
||||
|
||||
# ML 模型配置
|
||||
'ml_checkpoint_dir': 'ml/checkpoints',
|
||||
'ml_threshold_key': 'p95', # p90, p95, p99
|
||||
|
||||
# Alpha 配置(与 concept_alert_alpha.py 一致)
|
||||
'alpha_zscore_threshold': 2.0,
|
||||
'alpha_absolute_threshold': 1.5,
|
||||
'alpha_history_window': 60,
|
||||
'alpha_min_history': 5,
|
||||
|
||||
# 共享配置
|
||||
'cooldown_minutes': 8,
|
||||
'max_alerts_per_minute': 15,
|
||||
'min_alpha_abs': 0.5,
|
||||
}
|
||||
|
||||
# 特征配置(与训练一致)
|
||||
FEATURE_NAMES = [
|
||||
'alpha',
|
||||
'alpha_delta',
|
||||
'amt_ratio',
|
||||
'amt_delta',
|
||||
'rank_pct',
|
||||
'limit_up_ratio',
|
||||
]
|
||||
|
||||
|
||||
# ==================== 数据结构 ====================
|
||||
|
||||
@dataclass
|
||||
class AlphaStats:
|
||||
"""概念的Alpha统计信息"""
|
||||
history: deque = field(default_factory=lambda: deque(maxlen=ENHANCED_CONFIG['alpha_history_window']))
|
||||
mean: float = 0.0
|
||||
std: float = 1.0
|
||||
|
||||
def update(self, alpha: float):
|
||||
self.history.append(alpha)
|
||||
if len(self.history) >= 2:
|
||||
self.mean = np.mean(self.history)
|
||||
self.std = max(np.std(self.history), 0.1)
|
||||
|
||||
def get_zscore(self, alpha: float) -> float:
|
||||
if len(self.history) < ENHANCED_CONFIG['alpha_min_history']:
|
||||
return 0.0
|
||||
return (alpha - self.mean) / self.std
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
return len(self.history) >= ENHANCED_CONFIG['alpha_min_history']
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConceptFeatures:
|
||||
"""概念的实时特征"""
|
||||
alpha: float = 0.0
|
||||
alpha_delta: float = 0.0
|
||||
amt_ratio: float = 1.0
|
||||
amt_delta: float = 0.0
|
||||
rank_pct: float = 0.5
|
||||
limit_up_ratio: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, float]:
|
||||
return {
|
||||
'alpha': self.alpha,
|
||||
'alpha_delta': self.alpha_delta,
|
||||
'amt_ratio': self.amt_ratio,
|
||||
'amt_delta': self.amt_delta,
|
||||
'rank_pct': self.rank_pct,
|
||||
'limit_up_ratio': self.limit_up_ratio,
|
||||
}
|
||||
|
||||
|
||||
# ==================== 增强检测器 ====================
|
||||
|
||||
class EnhancedAnomalyDetector:
|
||||
"""
|
||||
增强版异动检测器
|
||||
|
||||
融合 Alpha-based 和 ML 两种方法
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict = None,
|
||||
ml_enabled: bool = True
|
||||
):
|
||||
self.config = config or ENHANCED_CONFIG
|
||||
self.ml_enabled = ml_enabled
|
||||
self.ml_detector = None
|
||||
|
||||
# Alpha 统计
|
||||
self.alpha_stats: Dict[str, AlphaStats] = {}
|
||||
|
||||
# 特征历史(用于计算 delta)
|
||||
self.feature_history: Dict[str, deque] = {}
|
||||
|
||||
# 冷却记录
|
||||
self.cooldown_cache: Dict[str, datetime] = {}
|
||||
|
||||
# 尝试加载 ML 模型
|
||||
if ml_enabled:
|
||||
self._load_ml_model()
|
||||
|
||||
logger.info(f"EnhancedAnomalyDetector 初始化完成")
|
||||
logger.info(f" 融合模式: {self.config['fusion_mode']}")
|
||||
logger.info(f" ML 可用: {self.ml_detector is not None}")
|
||||
|
||||
def _load_ml_model(self):
|
||||
"""加载 ML 模型"""
|
||||
try:
|
||||
from inference import ConceptAnomalyDetector
|
||||
checkpoint_dir = Path(__file__).parent / 'checkpoints'
|
||||
|
||||
if (checkpoint_dir / 'best_model.pt').exists():
|
||||
self.ml_detector = ConceptAnomalyDetector(
|
||||
checkpoint_dir=str(checkpoint_dir),
|
||||
threshold_key=self.config['ml_threshold_key']
|
||||
)
|
||||
logger.info("ML 模型加载成功")
|
||||
else:
|
||||
logger.warning(f"ML 模型不存在: {checkpoint_dir / 'best_model.pt'}")
|
||||
except Exception as e:
|
||||
logger.warning(f"ML 模型加载失败: {e}")
|
||||
self.ml_detector = None
|
||||
|
||||
def _get_alpha_stats(self, concept_id: str) -> AlphaStats:
|
||||
"""获取或创建 Alpha 统计"""
|
||||
if concept_id not in self.alpha_stats:
|
||||
self.alpha_stats[concept_id] = AlphaStats()
|
||||
return self.alpha_stats[concept_id]
|
||||
|
||||
def _get_feature_history(self, concept_id: str) -> deque:
|
||||
"""获取特征历史"""
|
||||
if concept_id not in self.feature_history:
|
||||
self.feature_history[concept_id] = deque(maxlen=10)
|
||||
return self.feature_history[concept_id]
|
||||
|
||||
def _check_cooldown(self, concept_id: str, current_time: datetime) -> bool:
|
||||
"""检查冷却"""
|
||||
if concept_id not in self.cooldown_cache:
|
||||
return False
|
||||
|
||||
last_alert = self.cooldown_cache[concept_id]
|
||||
cooldown_td = (current_time - last_alert).total_seconds() / 60
|
||||
|
||||
return cooldown_td < self.config['cooldown_minutes']
|
||||
|
||||
def _set_cooldown(self, concept_id: str, current_time: datetime):
|
||||
"""设置冷却"""
|
||||
self.cooldown_cache[concept_id] = current_time
|
||||
|
||||
def compute_features(
|
||||
self,
|
||||
concept_id: str,
|
||||
alpha: float,
|
||||
amt_ratio: float,
|
||||
rank_pct: float,
|
||||
limit_up_ratio: float
|
||||
) -> ConceptFeatures:
|
||||
"""
|
||||
计算概念的完整特征
|
||||
|
||||
Args:
|
||||
concept_id: 概念ID
|
||||
alpha: 当前超额收益
|
||||
amt_ratio: 成交额比率
|
||||
rank_pct: 排名百分位
|
||||
limit_up_ratio: 涨停股占比
|
||||
|
||||
Returns:
|
||||
完整特征
|
||||
"""
|
||||
history = self._get_feature_history(concept_id)
|
||||
|
||||
# 计算变化率
|
||||
alpha_delta = 0.0
|
||||
amt_delta = 0.0
|
||||
|
||||
if len(history) > 0:
|
||||
last_features = history[-1]
|
||||
alpha_delta = alpha - last_features.alpha
|
||||
if last_features.amt_ratio > 0:
|
||||
amt_delta = (amt_ratio - last_features.amt_ratio) / last_features.amt_ratio
|
||||
|
||||
features = ConceptFeatures(
|
||||
alpha=alpha,
|
||||
alpha_delta=alpha_delta,
|
||||
amt_ratio=amt_ratio,
|
||||
amt_delta=amt_delta,
|
||||
rank_pct=rank_pct,
|
||||
limit_up_ratio=limit_up_ratio,
|
||||
)
|
||||
|
||||
# 更新历史
|
||||
history.append(features)
|
||||
|
||||
return features
|
||||
|
||||
def detect_alpha_anomaly(
|
||||
self,
|
||||
concept_id: str,
|
||||
alpha: float
|
||||
) -> Tuple[bool, float, str]:
|
||||
"""
|
||||
Alpha-based 异动检测
|
||||
|
||||
Returns:
|
||||
is_anomaly: 是否异动
|
||||
score: 异动分数(Z-Score 绝对值)
|
||||
reason: 触发原因
|
||||
"""
|
||||
stats = self._get_alpha_stats(concept_id)
|
||||
|
||||
# 计算 Z-Score(在更新前)
|
||||
zscore = stats.get_zscore(alpha)
|
||||
|
||||
# 更新统计
|
||||
stats.update(alpha)
|
||||
|
||||
# 判断
|
||||
if stats.is_ready():
|
||||
if abs(zscore) >= self.config['alpha_zscore_threshold']:
|
||||
return True, abs(zscore), f"Z={zscore:.2f}"
|
||||
else:
|
||||
if abs(alpha) >= self.config['alpha_absolute_threshold']:
|
||||
fake_zscore = alpha / 0.5
|
||||
return True, abs(fake_zscore), f"Alpha={alpha:+.2f}%"
|
||||
|
||||
return False, abs(zscore) if zscore else 0.0, ""
|
||||
|
||||
def detect_ml_anomaly(
|
||||
self,
|
||||
concept_id: str,
|
||||
features: ConceptFeatures
|
||||
) -> Tuple[bool, float]:
|
||||
"""
|
||||
ML-based 异动检测
|
||||
|
||||
Returns:
|
||||
is_anomaly: 是否异动
|
||||
score: 异动分数(重构误差)
|
||||
"""
|
||||
if self.ml_detector is None:
|
||||
return False, 0.0
|
||||
|
||||
try:
|
||||
is_anomaly, score = self.ml_detector.detect(
|
||||
concept_id,
|
||||
features.to_dict()
|
||||
)
|
||||
return is_anomaly, score or 0.0
|
||||
except Exception as e:
|
||||
logger.warning(f"ML 检测失败: {e}")
|
||||
return False, 0.0
|
||||
|
||||
def detect(
|
||||
self,
|
||||
concept_id: str,
|
||||
concept_name: str,
|
||||
alpha: float,
|
||||
amt_ratio: float,
|
||||
rank_pct: float,
|
||||
limit_up_ratio: float,
|
||||
change_pct: float,
|
||||
index_change: float,
|
||||
current_time: datetime,
|
||||
**extra_data
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
融合检测
|
||||
|
||||
Args:
|
||||
concept_id: 概念ID
|
||||
concept_name: 概念名称
|
||||
alpha: 超额收益
|
||||
amt_ratio: 成交额比率
|
||||
rank_pct: 排名百分位
|
||||
limit_up_ratio: 涨停股占比
|
||||
change_pct: 概念涨跌幅
|
||||
index_change: 大盘涨跌幅
|
||||
current_time: 当前时间
|
||||
**extra_data: 其他数据(limit_up_count, stock_count 等)
|
||||
|
||||
Returns:
|
||||
异动信息(如果触发),否则 None
|
||||
"""
|
||||
# Alpha 太小,不关注
|
||||
if abs(alpha) < self.config['min_alpha_abs']:
|
||||
return None
|
||||
|
||||
# 检查冷却
|
||||
if self._check_cooldown(concept_id, current_time):
|
||||
return None
|
||||
|
||||
# 计算特征
|
||||
features = self.compute_features(
|
||||
concept_id, alpha, amt_ratio, rank_pct, limit_up_ratio
|
||||
)
|
||||
|
||||
# 执行检测
|
||||
fusion_mode = self.config['fusion_mode']
|
||||
|
||||
alpha_anomaly, alpha_score, alpha_reason = self.detect_alpha_anomaly(concept_id, alpha)
|
||||
ml_anomaly, ml_score = False, 0.0
|
||||
|
||||
if fusion_mode in ('ml_only', 'adaptive', 'ensemble'):
|
||||
ml_anomaly, ml_score = self.detect_ml_anomaly(concept_id, features)
|
||||
|
||||
# 根据融合模式判断
|
||||
is_anomaly = False
|
||||
final_score = 0.0
|
||||
detection_method = ''
|
||||
|
||||
if fusion_mode == 'alpha_only':
|
||||
is_anomaly = alpha_anomaly
|
||||
final_score = alpha_score
|
||||
detection_method = 'alpha'
|
||||
|
||||
elif fusion_mode == 'ml_only':
|
||||
is_anomaly = ml_anomaly
|
||||
final_score = ml_score
|
||||
detection_method = 'ml'
|
||||
|
||||
elif fusion_mode == 'adaptive':
|
||||
# 优先 ML,回退 Alpha
|
||||
if self.ml_detector and ml_score > 0:
|
||||
is_anomaly = ml_anomaly
|
||||
final_score = ml_score
|
||||
detection_method = 'ml'
|
||||
else:
|
||||
is_anomaly = alpha_anomaly
|
||||
final_score = alpha_score
|
||||
detection_method = 'alpha'
|
||||
|
||||
elif fusion_mode == 'ensemble':
|
||||
# 加权融合
|
||||
# 归一化分数
|
||||
norm_alpha = min(alpha_score / 5.0, 1.0) # Z > 5 视为 1.0
|
||||
norm_ml = min(ml_score / (self.ml_detector.threshold if self.ml_detector else 1.0), 1.0)
|
||||
|
||||
final_score = (
|
||||
self.config['alpha_weight'] * norm_alpha +
|
||||
self.config['ml_weight'] * norm_ml
|
||||
)
|
||||
is_anomaly = final_score > 0.5 or alpha_anomaly or ml_anomaly
|
||||
detection_method = 'ensemble'
|
||||
|
||||
if not is_anomaly:
|
||||
return None
|
||||
|
||||
# 构建异动记录
|
||||
self._set_cooldown(concept_id, current_time)
|
||||
|
||||
alert_type = 'surge_up' if alpha > 0 else 'surge_down'
|
||||
|
||||
alert = {
|
||||
'concept_id': concept_id,
|
||||
'concept_name': concept_name,
|
||||
'alert_type': alert_type,
|
||||
'alert_time': current_time,
|
||||
'change_pct': change_pct,
|
||||
'alpha': alpha,
|
||||
'alpha_zscore': alpha_score,
|
||||
'index_change_pct': index_change,
|
||||
'detection_method': detection_method,
|
||||
'alpha_score': alpha_score,
|
||||
'ml_score': ml_score,
|
||||
'final_score': final_score,
|
||||
**extra_data
|
||||
}
|
||||
|
||||
return alert
|
||||
|
||||
def batch_detect(
|
||||
self,
|
||||
concepts_data: List[Dict],
|
||||
current_time: datetime
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
批量检测
|
||||
|
||||
Args:
|
||||
concepts_data: 概念数据列表
|
||||
current_time: 当前时间
|
||||
|
||||
Returns:
|
||||
异动列表(按分数排序,限制数量)
|
||||
"""
|
||||
alerts = []
|
||||
|
||||
for data in concepts_data:
|
||||
alert = self.detect(
|
||||
concept_id=data['concept_id'],
|
||||
concept_name=data['concept_name'],
|
||||
alpha=data.get('alpha', 0),
|
||||
amt_ratio=data.get('amt_ratio', 1.0),
|
||||
rank_pct=data.get('rank_pct', 0.5),
|
||||
limit_up_ratio=data.get('limit_up_ratio', 0),
|
||||
change_pct=data.get('change_pct', 0),
|
||||
index_change=data.get('index_change', 0),
|
||||
current_time=current_time,
|
||||
limit_up_count=data.get('limit_up_count', 0),
|
||||
limit_down_count=data.get('limit_down_count', 0),
|
||||
stock_count=data.get('stock_count', 0),
|
||||
concept_type=data.get('concept_type', 'leaf'),
|
||||
)
|
||||
|
||||
if alert:
|
||||
alerts.append(alert)
|
||||
|
||||
# 排序并限制数量
|
||||
alerts.sort(key=lambda x: x['final_score'], reverse=True)
|
||||
return alerts[:self.config['max_alerts_per_minute']]
|
||||
|
||||
def reset(self):
|
||||
"""重置所有状态(新交易日)"""
|
||||
self.alpha_stats.clear()
|
||||
self.feature_history.clear()
|
||||
self.cooldown_cache.clear()
|
||||
|
||||
if self.ml_detector:
|
||||
self.ml_detector.clear_history()
|
||||
|
||||
logger.info("检测器状态已重置")
|
||||
|
||||
|
||||
# ==================== 测试 ====================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import random
|
||||
|
||||
print("测试 EnhancedAnomalyDetector...")
|
||||
|
||||
# 初始化
|
||||
detector = EnhancedAnomalyDetector(ml_enabled=False) # 不加载 ML(可能不存在)
|
||||
|
||||
# 模拟数据
|
||||
concepts = [
|
||||
{'concept_id': 'ai_001', 'concept_name': '人工智能'},
|
||||
{'concept_id': 'chip_002', 'concept_name': '芯片半导体'},
|
||||
{'concept_id': 'car_003', 'concept_name': '新能源汽车'},
|
||||
]
|
||||
|
||||
print("\n模拟实时检测...")
|
||||
current_time = datetime.now()
|
||||
|
||||
for minute in range(50):
|
||||
concepts_data = []
|
||||
|
||||
for c in concepts:
|
||||
# 生成随机数据
|
||||
alpha = random.gauss(0, 0.8)
|
||||
amt_ratio = max(0.3, random.gauss(1, 0.3))
|
||||
rank_pct = random.random()
|
||||
limit_up_ratio = random.random() * 0.1
|
||||
|
||||
# 模拟异动(第30分钟人工智能暴涨)
|
||||
if minute == 30 and c['concept_id'] == 'ai_001':
|
||||
alpha = 4.5
|
||||
amt_ratio = 2.5
|
||||
limit_up_ratio = 0.3
|
||||
|
||||
concepts_data.append({
|
||||
**c,
|
||||
'alpha': alpha,
|
||||
'amt_ratio': amt_ratio,
|
||||
'rank_pct': rank_pct,
|
||||
'limit_up_ratio': limit_up_ratio,
|
||||
'change_pct': alpha + 0.5,
|
||||
'index_change': 0.5,
|
||||
})
|
||||
|
||||
# 检测
|
||||
alerts = detector.batch_detect(concepts_data, current_time)
|
||||
|
||||
if alerts:
|
||||
for alert in alerts:
|
||||
print(f" t={minute:02d} 🔥 {alert['concept_name']} "
|
||||
f"Alpha={alert['alpha']:+.2f}% "
|
||||
f"Score={alert['final_score']:.2f} "
|
||||
f"Method={alert['detection_method']}")
|
||||
|
||||
current_time = current_time.replace(minute=current_time.minute + 1 if current_time.minute < 59 else 0)
|
||||
|
||||
print("\n测试完成!")
|
||||
455
ml/inference.py
@@ -1,455 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
概念异动检测推理服务
|
||||
|
||||
在实时场景中使用训练好的 Transformer Autoencoder 进行异动检测
|
||||
|
||||
使用方法:
|
||||
from ml.inference import ConceptAnomalyDetector
|
||||
|
||||
detector = ConceptAnomalyDetector('ml/checkpoints')
|
||||
|
||||
# 检测异动
|
||||
features = {...} # 实时特征数据
|
||||
is_anomaly, score = detector.detect(features)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from model import TransformerAutoencoder
|
||||
|
||||
|
||||
class ConceptAnomalyDetector:
|
||||
"""
|
||||
概念异动检测器
|
||||
|
||||
使用训练好的 Transformer Autoencoder 进行实时异动检测
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
checkpoint_dir: str = 'ml/checkpoints',
|
||||
device: str = 'auto',
|
||||
threshold_key: str = 'p95'
|
||||
):
|
||||
"""
|
||||
初始化检测器
|
||||
|
||||
Args:
|
||||
checkpoint_dir: 模型检查点目录
|
||||
device: 设备 (auto/cuda/cpu)
|
||||
threshold_key: 使用的阈值键 (p90/p95/p99)
|
||||
"""
|
||||
self.checkpoint_dir = Path(checkpoint_dir)
|
||||
self.threshold_key = threshold_key
|
||||
|
||||
# 设备选择
|
||||
if device == 'auto':
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
else:
|
||||
self.device = torch.device(device)
|
||||
|
||||
# 加载配置
|
||||
self._load_config()
|
||||
|
||||
# 加载模型
|
||||
self._load_model()
|
||||
|
||||
# 加载阈值
|
||||
self._load_thresholds()
|
||||
|
||||
# 加载标准化统计量
|
||||
self._load_normalization_stats()
|
||||
|
||||
# 概念历史数据缓存
|
||||
# {concept_name: deque(maxlen=seq_len)}
|
||||
self.history_cache: Dict[str, deque] = {}
|
||||
|
||||
print(f"ConceptAnomalyDetector 初始化完成")
|
||||
print(f" 设备: {self.device}")
|
||||
print(f" 阈值: {self.threshold_key} = {self.threshold:.6f}")
|
||||
print(f" 序列长度: {self.seq_len}")
|
||||
|
||||
def _load_config(self):
|
||||
"""加载配置"""
|
||||
config_path = self.checkpoint_dir / 'config.json'
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {config_path}")
|
||||
|
||||
with open(config_path, 'r') as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
self.features = self.config['features']
|
||||
self.seq_len = self.config['seq_len']
|
||||
self.model_config = self.config['model']
|
||||
|
||||
def _load_model(self):
|
||||
"""加载模型"""
|
||||
model_path = self.checkpoint_dir / 'best_model.pt'
|
||||
if not model_path.exists():
|
||||
raise FileNotFoundError(f"模型文件不存在: {model_path}")
|
||||
|
||||
# 创建模型
|
||||
self.model = TransformerAutoencoder(**self.model_config)
|
||||
|
||||
# 加载权重
|
||||
checkpoint = torch.load(model_path, map_location=self.device)
|
||||
self.model.load_state_dict(checkpoint['model_state_dict'])
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
print(f"模型已加载: {model_path}")
|
||||
|
||||
def _load_thresholds(self):
|
||||
"""加载阈值"""
|
||||
thresholds_path = self.checkpoint_dir / 'thresholds.json'
|
||||
if not thresholds_path.exists():
|
||||
raise FileNotFoundError(f"阈值文件不存在: {thresholds_path}")
|
||||
|
||||
with open(thresholds_path, 'r') as f:
|
||||
self.thresholds = json.load(f)
|
||||
|
||||
if self.threshold_key not in self.thresholds:
|
||||
available_keys = list(self.thresholds.keys())
|
||||
raise KeyError(f"阈值键 '{self.threshold_key}' 不存在,可用: {available_keys}")
|
||||
|
||||
self.threshold = self.thresholds[self.threshold_key]
|
||||
|
||||
def _load_normalization_stats(self):
|
||||
"""加载标准化统计量"""
|
||||
stats_path = self.checkpoint_dir / 'normalization_stats.json'
|
||||
if not stats_path.exists():
|
||||
raise FileNotFoundError(f"标准化统计量文件不存在: {stats_path}")
|
||||
|
||||
with open(stats_path, 'r') as f:
|
||||
stats = json.load(f)
|
||||
|
||||
self.norm_mean = np.array(stats['mean'])
|
||||
self.norm_std = np.array(stats['std'])
|
||||
|
||||
def normalize(self, features: np.ndarray) -> np.ndarray:
|
||||
"""标准化特征"""
|
||||
return (features - self.norm_mean) / self.norm_std
|
||||
|
||||
def update_history(
|
||||
self,
|
||||
concept_name: str,
|
||||
features: Dict[str, float]
|
||||
):
|
||||
"""
|
||||
更新概念历史数据
|
||||
|
||||
Args:
|
||||
concept_name: 概念名称
|
||||
features: 当前时刻的特征字典
|
||||
"""
|
||||
# 初始化历史缓存
|
||||
if concept_name not in self.history_cache:
|
||||
self.history_cache[concept_name] = deque(maxlen=self.seq_len)
|
||||
|
||||
# 提取特征向量
|
||||
feature_vector = np.array([
|
||||
features.get(f, 0.0) for f in self.features
|
||||
])
|
||||
|
||||
# 处理异常值
|
||||
feature_vector = np.nan_to_num(feature_vector, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
# 添加到历史
|
||||
self.history_cache[concept_name].append(feature_vector)
|
||||
|
||||
def get_history_length(self, concept_name: str) -> int:
|
||||
"""获取概念的历史数据长度"""
|
||||
if concept_name not in self.history_cache:
|
||||
return 0
|
||||
return len(self.history_cache[concept_name])
|
||||
|
||||
@torch.no_grad()
|
||||
def detect(
|
||||
self,
|
||||
concept_name: str,
|
||||
features: Dict[str, float] = None,
|
||||
return_score: bool = True
|
||||
) -> Tuple[bool, Optional[float]]:
|
||||
"""
|
||||
检测概念是否异动
|
||||
|
||||
Args:
|
||||
concept_name: 概念名称
|
||||
features: 当前时刻的特征(如果提供,会先更新历史)
|
||||
return_score: 是否返回异动分数
|
||||
|
||||
Returns:
|
||||
is_anomaly: 是否异动
|
||||
score: 异动分数(如果 return_score=True)
|
||||
"""
|
||||
# 更新历史
|
||||
if features is not None:
|
||||
self.update_history(concept_name, features)
|
||||
|
||||
# 检查历史数据是否足够
|
||||
if concept_name not in self.history_cache:
|
||||
return False, None
|
||||
|
||||
history = self.history_cache[concept_name]
|
||||
if len(history) < self.seq_len:
|
||||
return False, None
|
||||
|
||||
# 构建输入序列
|
||||
sequence = np.array(list(history)) # (seq_len, n_features)
|
||||
|
||||
# 标准化
|
||||
sequence = self.normalize(sequence)
|
||||
|
||||
# 转为 tensor
|
||||
x = torch.FloatTensor(sequence).unsqueeze(0) # (1, seq_len, n_features)
|
||||
x = x.to(self.device)
|
||||
|
||||
# 计算重构误差
|
||||
error = self.model.compute_reconstruction_error(x, reduction='none')
|
||||
|
||||
# 取最后一个时刻的误差作为当前分数
|
||||
score = error[0, -1].item()
|
||||
|
||||
# 判断是否异动
|
||||
is_anomaly = score > self.threshold
|
||||
|
||||
if return_score:
|
||||
return is_anomaly, score
|
||||
else:
|
||||
return is_anomaly, None
|
||||
|
||||
@torch.no_grad()
|
||||
def batch_detect(
|
||||
self,
|
||||
concept_features: Dict[str, Dict[str, float]]
|
||||
) -> Dict[str, Tuple[bool, float]]:
|
||||
"""
|
||||
批量检测多个概念
|
||||
|
||||
Args:
|
||||
concept_features: {concept_name: {feature_name: value}}
|
||||
|
||||
Returns:
|
||||
results: {concept_name: (is_anomaly, score)}
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for concept_name, features in concept_features.items():
|
||||
is_anomaly, score = self.detect(concept_name, features)
|
||||
results[concept_name] = (is_anomaly, score)
|
||||
|
||||
return results
|
||||
|
||||
def get_anomaly_type(
|
||||
self,
|
||||
concept_name: str,
|
||||
features: Dict[str, float]
|
||||
) -> str:
|
||||
"""
|
||||
判断异动类型
|
||||
|
||||
Args:
|
||||
concept_name: 概念名称
|
||||
features: 当前特征
|
||||
|
||||
Returns:
|
||||
anomaly_type: 'surge_up' / 'surge_down' / 'normal'
|
||||
"""
|
||||
is_anomaly, score = self.detect(concept_name, features)
|
||||
|
||||
if not is_anomaly:
|
||||
return 'normal'
|
||||
|
||||
# 根据 alpha 判断涨跌
|
||||
alpha = features.get('alpha', 0.0)
|
||||
|
||||
if alpha > 0:
|
||||
return 'surge_up'
|
||||
else:
|
||||
return 'surge_down'
|
||||
|
||||
def get_top_anomalies(
|
||||
self,
|
||||
concept_features: Dict[str, Dict[str, float]],
|
||||
top_k: int = 10
|
||||
) -> List[Tuple[str, float, str]]:
|
||||
"""
|
||||
获取异动分数最高的 top_k 个概念
|
||||
|
||||
Args:
|
||||
concept_features: {concept_name: {feature_name: value}}
|
||||
top_k: 返回数量
|
||||
|
||||
Returns:
|
||||
anomalies: [(concept_name, score, anomaly_type), ...]
|
||||
"""
|
||||
results = self.batch_detect(concept_features)
|
||||
|
||||
# 按分数排序
|
||||
sorted_results = sorted(
|
||||
[(name, is_anomaly, score) for name, (is_anomaly, score) in results.items() if score is not None],
|
||||
key=lambda x: x[2],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# 取 top_k
|
||||
top_anomalies = []
|
||||
for name, is_anomaly, score in sorted_results[:top_k]:
|
||||
if is_anomaly:
|
||||
alpha = concept_features[name].get('alpha', 0.0)
|
||||
anomaly_type = 'surge_up' if alpha > 0 else 'surge_down'
|
||||
top_anomalies.append((name, score, anomaly_type))
|
||||
|
||||
return top_anomalies
|
||||
|
||||
def clear_history(self, concept_name: str = None):
|
||||
"""
|
||||
清除历史缓存
|
||||
|
||||
Args:
|
||||
concept_name: 概念名称(如果为 None,清除所有)
|
||||
"""
|
||||
if concept_name is None:
|
||||
self.history_cache.clear()
|
||||
elif concept_name in self.history_cache:
|
||||
del self.history_cache[concept_name]
|
||||
|
||||
|
||||
# ==================== 集成到现有系统 ====================
|
||||
|
||||
class MLAnomalyService:
|
||||
"""
|
||||
ML 异动检测服务
|
||||
|
||||
用于替换或增强现有的 Alpha-based 检测
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
checkpoint_dir: str = 'ml/checkpoints',
|
||||
fallback_to_alpha: bool = True
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
checkpoint_dir: 模型检查点目录
|
||||
fallback_to_alpha: 当 ML 模型不可用时是否回退到 Alpha 方法
|
||||
"""
|
||||
self.fallback_to_alpha = fallback_to_alpha
|
||||
self.ml_detector = None
|
||||
|
||||
try:
|
||||
self.ml_detector = ConceptAnomalyDetector(checkpoint_dir)
|
||||
print("ML 异动检测服务初始化成功")
|
||||
except Exception as e:
|
||||
print(f"ML 模型加载失败: {e}")
|
||||
if not fallback_to_alpha:
|
||||
raise
|
||||
print("将回退到 Alpha-based 检测")
|
||||
|
||||
def is_ml_available(self) -> bool:
|
||||
"""检查 ML 模型是否可用"""
|
||||
return self.ml_detector is not None
|
||||
|
||||
def detect_anomaly(
|
||||
self,
|
||||
concept_name: str,
|
||||
features: Dict[str, float],
|
||||
alpha_threshold: float = 2.0
|
||||
) -> Tuple[bool, float, str]:
|
||||
"""
|
||||
检测异动
|
||||
|
||||
Args:
|
||||
concept_name: 概念名称
|
||||
features: 特征字典(需包含 alpha, amt_ratio 等)
|
||||
alpha_threshold: Alpha Z-Score 阈值(用于回退)
|
||||
|
||||
Returns:
|
||||
is_anomaly: 是否异动
|
||||
score: 异动分数
|
||||
method: 检测方法 ('ml' / 'alpha')
|
||||
"""
|
||||
# 优先使用 ML 检测
|
||||
if self.ml_detector is not None:
|
||||
history_len = self.ml_detector.get_history_length(concept_name)
|
||||
|
||||
# 历史数据足够时使用 ML
|
||||
if history_len >= self.ml_detector.seq_len - 1:
|
||||
is_anomaly, score = self.ml_detector.detect(concept_name, features)
|
||||
if score is not None:
|
||||
return is_anomaly, score, 'ml'
|
||||
else:
|
||||
# 更新历史但使用 Alpha 方法
|
||||
self.ml_detector.update_history(concept_name, features)
|
||||
|
||||
# 回退到 Alpha 方法
|
||||
if self.fallback_to_alpha:
|
||||
alpha = features.get('alpha', 0.0)
|
||||
alpha_zscore = features.get('alpha_zscore', 0.0)
|
||||
|
||||
is_anomaly = abs(alpha_zscore) > alpha_threshold
|
||||
score = abs(alpha_zscore)
|
||||
|
||||
return is_anomaly, score, 'alpha'
|
||||
|
||||
return False, 0.0, 'none'
|
||||
|
||||
|
||||
# ==================== 测试 ====================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import random
|
||||
|
||||
print("测试 ConceptAnomalyDetector...")
|
||||
|
||||
# 检查模型是否存在
|
||||
checkpoint_dir = Path('ml/checkpoints')
|
||||
if not (checkpoint_dir / 'best_model.pt').exists():
|
||||
print("模型文件不存在,跳过测试")
|
||||
print("请先运行 train.py 训练模型")
|
||||
exit(0)
|
||||
|
||||
# 初始化检测器
|
||||
detector = ConceptAnomalyDetector('ml/checkpoints')
|
||||
|
||||
# 模拟数据
|
||||
print("\n模拟实时检测...")
|
||||
concept_name = "人工智能"
|
||||
|
||||
for i in range(40):
|
||||
# 生成随机特征
|
||||
features = {
|
||||
'alpha': random.gauss(0, 1),
|
||||
'alpha_delta': random.gauss(0, 0.5),
|
||||
'amt_ratio': random.gauss(1, 0.3),
|
||||
'amt_delta': random.gauss(0, 0.2),
|
||||
'rank_pct': random.random(),
|
||||
'limit_up_ratio': random.random() * 0.1,
|
||||
}
|
||||
|
||||
# 在第 35 分钟模拟异动
|
||||
if i == 35:
|
||||
features['alpha'] = 5.0
|
||||
features['alpha_delta'] = 2.0
|
||||
features['amt_ratio'] = 3.0
|
||||
|
||||
is_anomaly, score = detector.detect(concept_name, features)
|
||||
|
||||
history_len = detector.get_history_length(concept_name)
|
||||
|
||||
if score is not None:
|
||||
status = "🔥 异动!" if is_anomaly else "正常"
|
||||
print(f" t={i:02d} | 历史={history_len} | 分数={score:.4f} | {status}")
|
||||
else:
|
||||
print(f" t={i:02d} | 历史={history_len} | 数据不足")
|
||||
|
||||
print("\n测试完成!")
|
||||
393
ml/model.py
@@ -1,393 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LSTM Autoencoder 模型定义
|
||||
|
||||
用于概念异动检测:
|
||||
- 学习"正常"市场模式
|
||||
- 重构误差大的时刻 = 异动
|
||||
|
||||
模型结构(简洁有效):
|
||||
┌─────────────────────────────────────┐
|
||||
│ 输入: (batch, seq_len, n_features) │
|
||||
│ 过去30分钟的特征序列 │
|
||||
├─────────────────────────────────────┤
|
||||
│ LSTM Encoder │
|
||||
│ - 双向 LSTM │
|
||||
│ - 输出最后隐藏状态 │
|
||||
├─────────────────────────────────────┤
|
||||
│ Bottleneck (压缩层) │
|
||||
│ 降维到 latent_dim(关键!) │
|
||||
├─────────────────────────────────────┤
|
||||
│ LSTM Decoder │
|
||||
│ - 单向 LSTM │
|
||||
│ - 重构序列 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 输出: (batch, seq_len, n_features) │
|
||||
│ 重构的特征序列 │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
为什么用 LSTM 而不是 Transformer:
|
||||
1. 参数更少,不容易过拟合
|
||||
2. 对于 6 维特征足够用
|
||||
3. 训练更稳定
|
||||
4. 瓶颈约束更容易控制
|
||||
"""
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
class LSTMAutoencoder(nn.Module):
|
||||
"""
|
||||
LSTM Autoencoder for Anomaly Detection
|
||||
|
||||
设计原则:
|
||||
- 足够简单,避免过拟合
|
||||
- 瓶颈层严格限制,迫使模型只学习主要模式
|
||||
- 异常难以通过狭窄瓶颈,重构误差大
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
n_features: int = 6,
|
||||
hidden_dim: int = 32, # LSTM 隐藏维度(小!)
|
||||
latent_dim: int = 4, # 瓶颈维度(非常小!关键参数)
|
||||
num_layers: int = 1, # LSTM 层数
|
||||
dropout: float = 0.2,
|
||||
bidirectional: bool = True, # 双向编码器
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.n_features = n_features
|
||||
self.hidden_dim = hidden_dim
|
||||
self.latent_dim = latent_dim
|
||||
self.num_layers = num_layers
|
||||
self.bidirectional = bidirectional
|
||||
self.num_directions = 2 if bidirectional else 1
|
||||
|
||||
# Encoder: 双向 LSTM
|
||||
self.encoder = nn.LSTM(
|
||||
input_size=n_features,
|
||||
hidden_size=hidden_dim,
|
||||
num_layers=num_layers,
|
||||
batch_first=True,
|
||||
dropout=dropout if num_layers > 1 else 0,
|
||||
bidirectional=bidirectional
|
||||
)
|
||||
|
||||
# Bottleneck: 压缩到极小的 latent space
|
||||
encoder_output_dim = hidden_dim * self.num_directions
|
||||
self.bottleneck_down = nn.Sequential(
|
||||
nn.Linear(encoder_output_dim, latent_dim),
|
||||
nn.Tanh(), # 限制范围,增加约束
|
||||
)
|
||||
|
||||
# 使用 LeakyReLU 替代 ReLU
|
||||
# 原因:Z-Score 数据范围是 [-5, +5],ReLU 会截断负值,丢失跌幅信息
|
||||
# LeakyReLU 保留负值信号(乘以 0.1)
|
||||
self.bottleneck_up = nn.Sequential(
|
||||
nn.Linear(latent_dim, hidden_dim),
|
||||
nn.LeakyReLU(negative_slope=0.1),
|
||||
)
|
||||
|
||||
# Decoder: 单向 LSTM
|
||||
self.decoder = nn.LSTM(
|
||||
input_size=hidden_dim,
|
||||
hidden_size=hidden_dim,
|
||||
num_layers=num_layers,
|
||||
batch_first=True,
|
||||
dropout=dropout if num_layers > 1 else 0,
|
||||
bidirectional=False # 解码器用单向
|
||||
)
|
||||
|
||||
# 输出层
|
||||
self.output_layer = nn.Linear(hidden_dim, n_features)
|
||||
|
||||
# Dropout
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
|
||||
# 初始化
|
||||
self._init_weights()
|
||||
|
||||
def _init_weights(self):
|
||||
"""初始化权重"""
|
||||
for name, param in self.named_parameters():
|
||||
if 'weight_ih' in name:
|
||||
nn.init.xavier_uniform_(param)
|
||||
elif 'weight_hh' in name:
|
||||
nn.init.orthogonal_(param)
|
||||
elif 'bias' in name:
|
||||
nn.init.zeros_(param)
|
||||
|
||||
def encode(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
"""
|
||||
编码器
|
||||
|
||||
Args:
|
||||
x: (batch, seq_len, n_features)
|
||||
Returns:
|
||||
latent: (batch, seq_len, latent_dim) 每个时间步的压缩表示
|
||||
encoder_outputs: (batch, seq_len, hidden_dim * num_directions)
|
||||
"""
|
||||
# LSTM 编码
|
||||
encoder_outputs, (h_n, c_n) = self.encoder(x)
|
||||
# encoder_outputs: (batch, seq_len, hidden_dim * num_directions)
|
||||
|
||||
encoder_outputs = self.dropout(encoder_outputs)
|
||||
|
||||
# 压缩到 latent space(对每个时间步)
|
||||
latent = self.bottleneck_down(encoder_outputs)
|
||||
# latent: (batch, seq_len, latent_dim)
|
||||
|
||||
return latent, encoder_outputs
|
||||
|
||||
def decode(self, latent: torch.Tensor, seq_len: int) -> torch.Tensor:
|
||||
"""
|
||||
解码器
|
||||
|
||||
Args:
|
||||
latent: (batch, seq_len, latent_dim)
|
||||
seq_len: 序列长度
|
||||
Returns:
|
||||
output: (batch, seq_len, n_features)
|
||||
"""
|
||||
# 从 latent space 恢复
|
||||
decoder_input = self.bottleneck_up(latent)
|
||||
# decoder_input: (batch, seq_len, hidden_dim)
|
||||
|
||||
# LSTM 解码
|
||||
decoder_outputs, _ = self.decoder(decoder_input)
|
||||
# decoder_outputs: (batch, seq_len, hidden_dim)
|
||||
|
||||
decoder_outputs = self.dropout(decoder_outputs)
|
||||
|
||||
# 投影到原始特征空间
|
||||
output = self.output_layer(decoder_outputs)
|
||||
# output: (batch, seq_len, n_features)
|
||||
|
||||
return output
|
||||
|
||||
def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
"""
|
||||
前向传播
|
||||
|
||||
Args:
|
||||
x: (batch, seq_len, n_features)
|
||||
Returns:
|
||||
output: (batch, seq_len, n_features) 重构结果
|
||||
latent: (batch, seq_len, latent_dim) 隐向量
|
||||
"""
|
||||
batch_size, seq_len, _ = x.shape
|
||||
|
||||
# 编码
|
||||
latent, _ = self.encode(x)
|
||||
|
||||
# 解码
|
||||
output = self.decode(latent, seq_len)
|
||||
|
||||
return output, latent
|
||||
|
||||
def compute_reconstruction_error(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
reduction: str = 'none'
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
计算重构误差
|
||||
|
||||
Args:
|
||||
x: (batch, seq_len, n_features)
|
||||
reduction: 'none' | 'mean' | 'sum'
|
||||
Returns:
|
||||
error: 重构误差
|
||||
"""
|
||||
output, _ = self.forward(x)
|
||||
|
||||
# MSE per feature per timestep
|
||||
error = F.mse_loss(output, x, reduction='none')
|
||||
|
||||
if reduction == 'none':
|
||||
# (batch, seq_len, n_features) -> (batch, seq_len)
|
||||
return error.mean(dim=-1)
|
||||
elif reduction == 'mean':
|
||||
return error.mean()
|
||||
elif reduction == 'sum':
|
||||
return error.sum()
|
||||
else:
|
||||
raise ValueError(f"Unknown reduction: {reduction}")
|
||||
|
||||
def detect_anomaly(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
threshold: float = None,
|
||||
return_scores: bool = True
|
||||
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
|
||||
"""
|
||||
检测异动
|
||||
|
||||
Args:
|
||||
x: (batch, seq_len, n_features)
|
||||
threshold: 异动阈值(如果为 None,只返回分数)
|
||||
return_scores: 是否返回异动分数
|
||||
Returns:
|
||||
is_anomaly: (batch, seq_len) bool tensor (if threshold is not None)
|
||||
scores: (batch, seq_len) 异动分数 (if return_scores)
|
||||
"""
|
||||
scores = self.compute_reconstruction_error(x, reduction='none')
|
||||
|
||||
is_anomaly = None
|
||||
if threshold is not None:
|
||||
is_anomaly = scores > threshold
|
||||
|
||||
if return_scores:
|
||||
return is_anomaly, scores
|
||||
else:
|
||||
return is_anomaly, None
|
||||
|
||||
|
||||
# 为了兼容性,创建别名
|
||||
TransformerAutoencoder = LSTMAutoencoder
|
||||
|
||||
|
||||
# ==================== 损失函数 ====================
|
||||
|
||||
class AnomalyDetectionLoss(nn.Module):
|
||||
"""
|
||||
异动检测损失函数
|
||||
|
||||
简单的 MSE 重构损失
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
feature_weights: torch.Tensor = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.feature_weights = feature_weights
|
||||
|
||||
def forward(
|
||||
self,
|
||||
output: torch.Tensor,
|
||||
target: torch.Tensor,
|
||||
latent: torch.Tensor = None
|
||||
) -> Tuple[torch.Tensor, dict]:
|
||||
"""
|
||||
Args:
|
||||
output: (batch, seq_len, n_features) 重构结果
|
||||
target: (batch, seq_len, n_features) 原始输入
|
||||
latent: (batch, seq_len, latent_dim) 隐向量(未使用)
|
||||
Returns:
|
||||
loss: 总损失
|
||||
loss_dict: 各项损失详情
|
||||
"""
|
||||
# 重构损失 (MSE)
|
||||
mse = F.mse_loss(output, target, reduction='none')
|
||||
|
||||
# 特征加权(可选)
|
||||
if self.feature_weights is not None:
|
||||
weights = self.feature_weights.to(mse.device)
|
||||
mse = mse * weights
|
||||
|
||||
reconstruction_loss = mse.mean()
|
||||
|
||||
loss_dict = {
|
||||
'total': reconstruction_loss.item(),
|
||||
'reconstruction': reconstruction_loss.item(),
|
||||
}
|
||||
|
||||
return reconstruction_loss, loss_dict
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def count_parameters(model: nn.Module) -> int:
|
||||
"""统计模型参数量"""
|
||||
return sum(p.numel() for p in model.parameters() if p.requires_grad)
|
||||
|
||||
|
||||
def create_model(config: dict = None) -> LSTMAutoencoder:
|
||||
"""
|
||||
创建模型
|
||||
|
||||
默认使用小型 LSTM 配置,适合异动检测
|
||||
"""
|
||||
default_config = {
|
||||
'n_features': 6,
|
||||
'hidden_dim': 32, # 小!
|
||||
'latent_dim': 4, # 非常小!关键
|
||||
'num_layers': 1,
|
||||
'dropout': 0.2,
|
||||
'bidirectional': True,
|
||||
}
|
||||
|
||||
if config:
|
||||
# 兼容旧的 Transformer 配置键名
|
||||
if 'd_model' in config:
|
||||
config['hidden_dim'] = config.pop('d_model') // 2
|
||||
if 'num_encoder_layers' in config:
|
||||
config['num_layers'] = config.pop('num_encoder_layers')
|
||||
if 'num_decoder_layers' in config:
|
||||
config.pop('num_decoder_layers')
|
||||
if 'nhead' in config:
|
||||
config.pop('nhead')
|
||||
if 'dim_feedforward' in config:
|
||||
config.pop('dim_feedforward')
|
||||
if 'max_seq_len' in config:
|
||||
config.pop('max_seq_len')
|
||||
if 'use_instance_norm' in config:
|
||||
config.pop('use_instance_norm')
|
||||
|
||||
default_config.update(config)
|
||||
|
||||
model = LSTMAutoencoder(**default_config)
|
||||
param_count = count_parameters(model)
|
||||
print(f"模型参数量: {param_count:,}")
|
||||
|
||||
if param_count > 100000:
|
||||
print(f"⚠️ 警告: 参数量较大({param_count:,}),可能过拟合")
|
||||
else:
|
||||
print(f"✓ 参数量适中(LSTM Autoencoder)")
|
||||
|
||||
return model
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试模型
|
||||
print("测试 LSTM Autoencoder...")
|
||||
|
||||
# 创建模型
|
||||
model = create_model()
|
||||
|
||||
# 测试输入
|
||||
batch_size = 32
|
||||
seq_len = 30
|
||||
n_features = 6
|
||||
|
||||
x = torch.randn(batch_size, seq_len, n_features)
|
||||
|
||||
# 前向传播
|
||||
output, latent = model(x)
|
||||
|
||||
print(f"输入形状: {x.shape}")
|
||||
print(f"输出形状: {output.shape}")
|
||||
print(f"隐向量形状: {latent.shape}")
|
||||
|
||||
# 计算重构误差
|
||||
error = model.compute_reconstruction_error(x)
|
||||
print(f"重构误差形状: {error.shape}")
|
||||
print(f"平均重构误差: {error.mean().item():.4f}")
|
||||
|
||||
# 测试异动检测
|
||||
is_anomaly, scores = model.detect_anomaly(x, threshold=0.5)
|
||||
print(f"异动检测结果形状: {is_anomaly.shape if is_anomaly is not None else 'None'}")
|
||||
print(f"异动分数形状: {scores.shape}")
|
||||
|
||||
# 测试损失函数
|
||||
criterion = AnomalyDetectionLoss()
|
||||
loss, loss_dict = criterion(output, x, latent)
|
||||
print(f"损失: {loss.item():.4f}")
|
||||
|
||||
print("\n测试通过!")
|
||||
@@ -1,537 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据准备脚本 - 为 Transformer Autoencoder 准备训练数据
|
||||
|
||||
从 ClickHouse 提取历史分钟数据,计算以下特征:
|
||||
1. alpha - 超额收益(概念涨幅 - 大盘涨幅)
|
||||
2. alpha_delta - Alpha 变化率(5分钟)
|
||||
3. amt_ratio - 成交额相对均值(当前/过去20分钟均值)
|
||||
4. amt_delta - 成交额变化率
|
||||
5. rank_pct - Alpha 排名百分位
|
||||
6. limit_up_ratio - 涨停股占比
|
||||
|
||||
输出:按交易日存储的特征文件(parquet格式)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta, date
|
||||
from sqlalchemy import create_engine, text
|
||||
from elasticsearch import Elasticsearch
|
||||
from clickhouse_driver import Client
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Set, Tuple
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from multiprocessing import Manager
|
||||
import multiprocessing
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
MYSQL_ENGINE = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
|
||||
echo=False
|
||||
)
|
||||
|
||||
ES_CLIENT = Elasticsearch(['http://127.0.0.1:9200'])
|
||||
ES_INDEX = 'concept_library_v3'
|
||||
|
||||
CLICKHOUSE_CONFIG = {
|
||||
'host': '127.0.0.1',
|
||||
'port': 9000,
|
||||
'user': 'default',
|
||||
'password': 'Zzl33818!',
|
||||
'database': 'stock'
|
||||
}
|
||||
|
||||
# 输出目录
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'data')
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# 特征计算参数
|
||||
FEATURE_CONFIG = {
|
||||
'alpha_delta_window': 5, # Alpha变化窗口(分钟)
|
||||
'amt_ma_window': 20, # 成交额均值窗口(分钟)
|
||||
'limit_up_threshold': 9.8, # 涨停阈值(%)
|
||||
'limit_down_threshold': -9.8, # 跌停阈值(%)
|
||||
}
|
||||
|
||||
REFERENCE_INDEX = '000001.SH'
|
||||
|
||||
# ==================== 日志 ====================
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def get_ch_client():
|
||||
return Client(**CLICKHOUSE_CONFIG)
|
||||
|
||||
|
||||
def generate_id(name: str) -> str:
|
||||
return hashlib.md5(name.encode('utf-8')).hexdigest()[:16]
|
||||
|
||||
|
||||
def code_to_ch_format(code: str) -> str:
|
||||
if not code or len(code) != 6 or not code.isdigit():
|
||||
return None
|
||||
if code.startswith('6'):
|
||||
return f"{code}.SH"
|
||||
elif code.startswith('0') or code.startswith('3'):
|
||||
return f"{code}.SZ"
|
||||
else:
|
||||
return f"{code}.BJ"
|
||||
|
||||
|
||||
# ==================== 获取概念列表 ====================
|
||||
|
||||
def get_all_concepts() -> List[dict]:
|
||||
"""从ES获取所有叶子概念"""
|
||||
concepts = []
|
||||
|
||||
query = {
|
||||
"query": {"match_all": {}},
|
||||
"size": 100,
|
||||
"_source": ["concept_id", "concept", "stocks"]
|
||||
}
|
||||
|
||||
resp = ES_CLIENT.search(index=ES_INDEX, body=query, scroll='2m')
|
||||
scroll_id = resp['_scroll_id']
|
||||
hits = resp['hits']['hits']
|
||||
|
||||
while len(hits) > 0:
|
||||
for hit in hits:
|
||||
source = hit['_source']
|
||||
stocks = []
|
||||
if 'stocks' in source and isinstance(source['stocks'], list):
|
||||
for stock in source['stocks']:
|
||||
if isinstance(stock, dict) and 'code' in stock and stock['code']:
|
||||
stocks.append(stock['code'])
|
||||
|
||||
if stocks:
|
||||
concepts.append({
|
||||
'concept_id': source.get('concept_id'),
|
||||
'concept_name': source.get('concept'),
|
||||
'stocks': stocks
|
||||
})
|
||||
|
||||
resp = ES_CLIENT.scroll(scroll_id=scroll_id, scroll='2m')
|
||||
scroll_id = resp['_scroll_id']
|
||||
hits = resp['hits']['hits']
|
||||
|
||||
ES_CLIENT.clear_scroll(scroll_id=scroll_id)
|
||||
print(f"获取到 {len(concepts)} 个概念")
|
||||
return concepts
|
||||
|
||||
|
||||
# ==================== 获取交易日列表 ====================
|
||||
|
||||
def get_trading_days(start_date: str, end_date: str) -> List[str]:
|
||||
"""获取交易日列表"""
|
||||
client = get_ch_client()
|
||||
|
||||
query = f"""
|
||||
SELECT DISTINCT toDate(timestamp) as trade_date
|
||||
FROM stock_minute
|
||||
WHERE toDate(timestamp) >= '{start_date}'
|
||||
AND toDate(timestamp) <= '{end_date}'
|
||||
ORDER BY trade_date
|
||||
"""
|
||||
|
||||
result = client.execute(query)
|
||||
days = [row[0].strftime('%Y-%m-%d') for row in result]
|
||||
print(f"找到 {len(days)} 个交易日: {days[0]} ~ {days[-1]}")
|
||||
return days
|
||||
|
||||
|
||||
# ==================== 获取单日数据 ====================
|
||||
|
||||
def get_daily_stock_data(trade_date: str, stock_codes: List[str]) -> pd.DataFrame:
|
||||
"""获取单日所有股票的分钟数据"""
|
||||
client = get_ch_client()
|
||||
|
||||
# 转换代码格式
|
||||
ch_codes = []
|
||||
code_map = {}
|
||||
for code in stock_codes:
|
||||
ch_code = code_to_ch_format(code)
|
||||
if ch_code:
|
||||
ch_codes.append(ch_code)
|
||||
code_map[ch_code] = code
|
||||
|
||||
if not ch_codes:
|
||||
return pd.DataFrame()
|
||||
|
||||
ch_codes_str = "','".join(ch_codes)
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
code,
|
||||
timestamp,
|
||||
close,
|
||||
volume,
|
||||
amt
|
||||
FROM stock_minute
|
||||
WHERE toDate(timestamp) = '{trade_date}'
|
||||
AND code IN ('{ch_codes_str}')
|
||||
ORDER BY code, timestamp
|
||||
"""
|
||||
|
||||
result = client.execute(query)
|
||||
|
||||
if not result:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(result, columns=['ch_code', 'timestamp', 'close', 'volume', 'amt'])
|
||||
df['code'] = df['ch_code'].map(code_map)
|
||||
df = df.dropna(subset=['code'])
|
||||
|
||||
return df[['code', 'timestamp', 'close', 'volume', 'amt']]
|
||||
|
||||
|
||||
def get_daily_index_data(trade_date: str, index_code: str = REFERENCE_INDEX) -> pd.DataFrame:
|
||||
"""获取单日指数分钟数据"""
|
||||
client = get_ch_client()
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
timestamp,
|
||||
close,
|
||||
volume,
|
||||
amt
|
||||
FROM index_minute
|
||||
WHERE toDate(timestamp) = '{trade_date}'
|
||||
AND code = '{index_code}'
|
||||
ORDER BY timestamp
|
||||
"""
|
||||
|
||||
result = client.execute(query)
|
||||
|
||||
if not result:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(result, columns=['timestamp', 'close', 'volume', 'amt'])
|
||||
return df
|
||||
|
||||
|
||||
def get_prev_close(stock_codes: List[str], trade_date: str) -> Dict[str, float]:
|
||||
"""获取昨收价(上一交易日的收盘价 F007N)"""
|
||||
valid_codes = [c for c in stock_codes if c and len(c) == 6 and c.isdigit()]
|
||||
if not valid_codes:
|
||||
return {}
|
||||
|
||||
codes_str = "','".join(valid_codes)
|
||||
|
||||
# 注意:F007N 是"最近成交价"即当日收盘价,F002N 是"昨日收盘价"
|
||||
# 我们需要查上一交易日的 F007N(那天的收盘价)作为今天的昨收
|
||||
query = f"""
|
||||
SELECT SECCODE, F007N
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ('{codes_str}')
|
||||
AND TRADEDATE = (
|
||||
SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}'
|
||||
)
|
||||
AND F007N IS NOT NULL AND F007N > 0
|
||||
"""
|
||||
|
||||
try:
|
||||
with MYSQL_ENGINE.connect() as conn:
|
||||
result = conn.execute(text(query))
|
||||
return {row[0]: float(row[1]) for row in result if row[1]}
|
||||
except Exception as e:
|
||||
print(f"获取昨收价失败: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_index_prev_close(trade_date: str, index_code: str = REFERENCE_INDEX) -> float:
|
||||
"""获取指数昨收价"""
|
||||
code_no_suffix = index_code.split('.')[0]
|
||||
|
||||
try:
|
||||
with MYSQL_ENGINE.connect() as conn:
|
||||
result = conn.execute(text("""
|
||||
SELECT F006N FROM ea_exchangetrade
|
||||
WHERE INDEXCODE = :code AND TRADEDATE < :today
|
||||
ORDER BY TRADEDATE DESC LIMIT 1
|
||||
"""), {'code': code_no_suffix, 'today': trade_date}).fetchone()
|
||||
|
||||
if result and result[0]:
|
||||
return float(result[0])
|
||||
except Exception as e:
|
||||
print(f"获取指数昨收失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ==================== 计算特征 ====================
|
||||
|
||||
def compute_daily_features(
|
||||
trade_date: str,
|
||||
concepts: List[dict],
|
||||
all_stocks: List[str]
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
计算单日所有概念的特征
|
||||
|
||||
返回 DataFrame:
|
||||
- index: (timestamp, concept_id)
|
||||
- columns: alpha, alpha_delta, amt_ratio, amt_delta, rank_pct, limit_up_ratio
|
||||
"""
|
||||
|
||||
# 1. 获取数据
|
||||
stock_df = get_daily_stock_data(trade_date, all_stocks)
|
||||
if stock_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
index_df = get_daily_index_data(trade_date)
|
||||
if index_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 2. 获取昨收价
|
||||
prev_close = get_prev_close(all_stocks, trade_date)
|
||||
index_prev_close = get_index_prev_close(trade_date)
|
||||
|
||||
if not prev_close or not index_prev_close:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 3. 计算股票涨跌幅和成交额
|
||||
stock_df['prev_close'] = stock_df['code'].map(prev_close)
|
||||
stock_df = stock_df.dropna(subset=['prev_close'])
|
||||
stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100
|
||||
|
||||
# 4. 计算指数涨跌幅
|
||||
index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100
|
||||
index_change_map = dict(zip(index_df['timestamp'], index_df['change_pct']))
|
||||
|
||||
# 5. 获取所有时间点
|
||||
timestamps = sorted(stock_df['timestamp'].unique())
|
||||
|
||||
# 6. 按时间点计算概念特征
|
||||
results = []
|
||||
|
||||
# 概念到股票的映射
|
||||
concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts}
|
||||
concept_names = {c['concept_id']: c['concept_name'] for c in concepts}
|
||||
|
||||
# 历史数据缓存(用于计算变化率)
|
||||
concept_history = {cid: {'alpha': [], 'amt': []} for cid in concept_stocks}
|
||||
|
||||
for ts in timestamps:
|
||||
ts_stock_data = stock_df[stock_df['timestamp'] == ts]
|
||||
index_change = index_change_map.get(ts, 0)
|
||||
|
||||
# 股票涨跌幅和成交额字典
|
||||
stock_change = dict(zip(ts_stock_data['code'], ts_stock_data['change_pct']))
|
||||
stock_amt = dict(zip(ts_stock_data['code'], ts_stock_data['amt']))
|
||||
|
||||
concept_features = []
|
||||
|
||||
for concept_id, stocks in concept_stocks.items():
|
||||
# 该概念的股票数据
|
||||
concept_changes = [stock_change[s] for s in stocks if s in stock_change]
|
||||
concept_amts = [stock_amt.get(s, 0) for s in stocks if s in stock_change]
|
||||
|
||||
if not concept_changes:
|
||||
continue
|
||||
|
||||
# 基础统计
|
||||
avg_change = np.mean(concept_changes)
|
||||
total_amt = sum(concept_amts)
|
||||
|
||||
# Alpha = 概念涨幅 - 指数涨幅
|
||||
alpha = avg_change - index_change
|
||||
|
||||
# 涨停/跌停股占比
|
||||
limit_up_count = sum(1 for c in concept_changes if c >= FEATURE_CONFIG['limit_up_threshold'])
|
||||
limit_down_count = sum(1 for c in concept_changes if c <= FEATURE_CONFIG['limit_down_threshold'])
|
||||
limit_up_ratio = limit_up_count / len(concept_changes)
|
||||
limit_down_ratio = limit_down_count / len(concept_changes)
|
||||
|
||||
# 更新历史
|
||||
history = concept_history[concept_id]
|
||||
history['alpha'].append(alpha)
|
||||
history['amt'].append(total_amt)
|
||||
|
||||
# 计算变化率
|
||||
alpha_delta = 0
|
||||
if len(history['alpha']) > FEATURE_CONFIG['alpha_delta_window']:
|
||||
alpha_delta = alpha - history['alpha'][-FEATURE_CONFIG['alpha_delta_window']-1]
|
||||
|
||||
# 成交额相对均值
|
||||
amt_ratio = 1.0
|
||||
amt_delta = 0
|
||||
if len(history['amt']) > FEATURE_CONFIG['amt_ma_window']:
|
||||
amt_ma = np.mean(history['amt'][-FEATURE_CONFIG['amt_ma_window']-1:-1])
|
||||
if amt_ma > 0:
|
||||
amt_ratio = total_amt / amt_ma
|
||||
amt_delta = total_amt - history['amt'][-2] if len(history['amt']) > 1 else 0
|
||||
|
||||
concept_features.append({
|
||||
'concept_id': concept_id,
|
||||
'alpha': alpha,
|
||||
'alpha_delta': alpha_delta,
|
||||
'amt_ratio': amt_ratio,
|
||||
'amt_delta': amt_delta,
|
||||
'limit_up_ratio': limit_up_ratio,
|
||||
'limit_down_ratio': limit_down_ratio,
|
||||
'total_amt': total_amt,
|
||||
'stock_count': len(concept_changes),
|
||||
})
|
||||
|
||||
if not concept_features:
|
||||
continue
|
||||
|
||||
# 计算排名百分位
|
||||
concept_df = pd.DataFrame(concept_features)
|
||||
concept_df['rank_pct'] = concept_df['alpha'].rank(pct=True)
|
||||
|
||||
# 添加时间戳
|
||||
concept_df['timestamp'] = ts
|
||||
results.append(concept_df)
|
||||
|
||||
if not results:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 合并所有时间点
|
||||
final_df = pd.concat(results, ignore_index=True)
|
||||
|
||||
# 标准化成交额变化率
|
||||
if 'amt_delta' in final_df.columns:
|
||||
amt_delta_std = final_df['amt_delta'].std()
|
||||
if amt_delta_std > 0:
|
||||
final_df['amt_delta'] = final_df['amt_delta'] / amt_delta_std
|
||||
|
||||
return final_df
|
||||
|
||||
|
||||
# ==================== 主流程 ====================
|
||||
|
||||
def process_single_day(args) -> Tuple[str, bool]:
|
||||
"""
|
||||
处理单个交易日(多进程版本)
|
||||
|
||||
Args:
|
||||
args: (trade_date, concepts, all_stocks) 元组
|
||||
|
||||
Returns:
|
||||
(trade_date, success) 元组
|
||||
"""
|
||||
trade_date, concepts, all_stocks = args
|
||||
output_file = os.path.join(OUTPUT_DIR, f'features_{trade_date}.parquet')
|
||||
|
||||
# 检查是否已处理
|
||||
if os.path.exists(output_file):
|
||||
print(f"[{trade_date}] 已存在,跳过")
|
||||
return (trade_date, True)
|
||||
|
||||
print(f"[{trade_date}] 开始处理...")
|
||||
|
||||
try:
|
||||
df = compute_daily_features(trade_date, concepts, all_stocks)
|
||||
|
||||
if df.empty:
|
||||
print(f"[{trade_date}] 无数据")
|
||||
return (trade_date, False)
|
||||
|
||||
# 保存
|
||||
df.to_parquet(output_file, index=False)
|
||||
print(f"[{trade_date}] 保存完成")
|
||||
return (trade_date, True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{trade_date}] 处理失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return (trade_date, False)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
from tqdm import tqdm
|
||||
|
||||
parser = argparse.ArgumentParser(description='准备训练数据')
|
||||
parser.add_argument('--start', type=str, default='2022-01-01', help='开始日期')
|
||||
parser.add_argument('--end', type=str, default=None, help='结束日期(默认今天)')
|
||||
parser.add_argument('--workers', type=int, default=18, help='并行进程数(默认18)')
|
||||
parser.add_argument('--force', action='store_true', help='强制重新处理已存在的文件')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
end_date = args.end or datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
print("=" * 60)
|
||||
print("数据准备 - Transformer Autoencoder 训练数据")
|
||||
print("=" * 60)
|
||||
print(f"日期范围: {args.start} ~ {end_date}")
|
||||
print(f"并行进程数: {args.workers}")
|
||||
|
||||
# 1. 获取概念列表
|
||||
concepts = get_all_concepts()
|
||||
|
||||
# 收集所有股票
|
||||
all_stocks = list(set(s for c in concepts for s in c['stocks']))
|
||||
print(f"股票总数: {len(all_stocks)}")
|
||||
|
||||
# 2. 获取交易日列表
|
||||
trading_days = get_trading_days(args.start, end_date)
|
||||
|
||||
if not trading_days:
|
||||
print("无交易日数据")
|
||||
return
|
||||
|
||||
# 如果强制模式,删除已有文件
|
||||
if args.force:
|
||||
for trade_date in trading_days:
|
||||
output_file = os.path.join(OUTPUT_DIR, f'features_{trade_date}.parquet')
|
||||
if os.path.exists(output_file):
|
||||
os.remove(output_file)
|
||||
print(f"删除已有文件: {output_file}")
|
||||
|
||||
# 3. 准备任务参数
|
||||
tasks = [(trade_date, concepts, all_stocks) for trade_date in trading_days]
|
||||
|
||||
print(f"\n开始处理 {len(trading_days)} 个交易日({args.workers} 进程并行)...")
|
||||
|
||||
# 4. 多进程处理
|
||||
success_count = 0
|
||||
failed_dates = []
|
||||
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as executor:
|
||||
# 提交所有任务
|
||||
futures = {executor.submit(process_single_day, task): task[0] for task in tasks}
|
||||
|
||||
# 使用 tqdm 显示进度
|
||||
with tqdm(total=len(futures), desc="处理进度", unit="天") as pbar:
|
||||
for future in as_completed(futures):
|
||||
trade_date = futures[future]
|
||||
try:
|
||||
result_date, success = future.result()
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
failed_dates.append(result_date)
|
||||
except Exception as e:
|
||||
print(f"\n[{trade_date}] 进程异常: {e}")
|
||||
failed_dates.append(trade_date)
|
||||
pbar.update(1)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"处理完成: {success_count}/{len(trading_days)} 个交易日")
|
||||
if failed_dates:
|
||||
print(f"失败日期: {failed_dates[:10]}{'...' if len(failed_dates) > 10 else ''}")
|
||||
print(f"数据保存在: {OUTPUT_DIR}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,715 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据准备 V2 - 基于时间片对齐的特征计算(修复版)
|
||||
|
||||
核心改进:
|
||||
1. 时间片对齐:9:35 和历史的 9:35 比,而不是和前30分钟比
|
||||
2. Z-Score 特征:相对于同时间片历史分布的偏离程度
|
||||
3. 滚动窗口基线:每个日期使用它之前 N 天的数据作为基线(不是固定的最后 N 天!)
|
||||
4. 基于 Z-Score 的动量:消除一天内波动率异构性
|
||||
|
||||
修复:
|
||||
- 滚动窗口基线:避免未来数据泄露
|
||||
- Z-Score 动量:消除早盘/尾盘波动率差异
|
||||
- 进程级数据库单例:避免连接池爆炸
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import create_engine, text
|
||||
from elasticsearch import Elasticsearch
|
||||
from clickhouse_driver import Client
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from tqdm import tqdm
|
||||
from collections import defaultdict
|
||||
import warnings
|
||||
import pickle
|
||||
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
MYSQL_URL = "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock"
|
||||
ES_HOST = 'http://127.0.0.1:9200'
|
||||
ES_INDEX = 'concept_library_v3'
|
||||
|
||||
CLICKHOUSE_CONFIG = {
|
||||
'host': '127.0.0.1',
|
||||
'port': 9000,
|
||||
'user': 'default',
|
||||
'password': 'Zzl33818!',
|
||||
'database': 'stock'
|
||||
}
|
||||
|
||||
REFERENCE_INDEX = '000001.SH'
|
||||
|
||||
# 输出目录
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'data_v2')
|
||||
BASELINE_DIR = os.path.join(OUTPUT_DIR, 'baselines')
|
||||
RAW_CACHE_DIR = os.path.join(OUTPUT_DIR, 'raw_cache')
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
os.makedirs(BASELINE_DIR, exist_ok=True)
|
||||
os.makedirs(RAW_CACHE_DIR, exist_ok=True)
|
||||
|
||||
# 特征配置
|
||||
CONFIG = {
|
||||
'baseline_days': 20, # 滚动窗口大小
|
||||
'min_baseline_samples': 10, # 最少需要10个样本才算有效基线
|
||||
'limit_up_threshold': 9.8,
|
||||
'limit_down_threshold': -9.8,
|
||||
'zscore_clip': 5.0,
|
||||
}
|
||||
|
||||
# 特征列表
|
||||
FEATURES_V2 = [
|
||||
'alpha', 'alpha_zscore', 'amt_zscore', 'rank_zscore',
|
||||
'momentum_3m', 'momentum_5m', 'limit_up_ratio',
|
||||
]
|
||||
|
||||
# ==================== 进程级单例(避免连接池爆炸)====================
|
||||
|
||||
# 进程级全局变量
|
||||
_process_mysql_engine = None
|
||||
_process_es_client = None
|
||||
_process_ch_client = None
|
||||
|
||||
|
||||
def init_process_connections():
|
||||
"""进程初始化时调用,创建连接(单例)"""
|
||||
global _process_mysql_engine, _process_es_client, _process_ch_client
|
||||
_process_mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True, pool_size=5)
|
||||
_process_es_client = Elasticsearch([ES_HOST])
|
||||
_process_ch_client = Client(**CLICKHOUSE_CONFIG)
|
||||
|
||||
|
||||
def get_mysql_engine():
|
||||
"""获取进程级 MySQL Engine(单例)"""
|
||||
global _process_mysql_engine
|
||||
if _process_mysql_engine is None:
|
||||
_process_mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True, pool_size=5)
|
||||
return _process_mysql_engine
|
||||
|
||||
|
||||
def get_es_client():
|
||||
"""获取进程级 ES 客户端(单例)"""
|
||||
global _process_es_client
|
||||
if _process_es_client is None:
|
||||
_process_es_client = Elasticsearch([ES_HOST])
|
||||
return _process_es_client
|
||||
|
||||
|
||||
def get_ch_client():
|
||||
"""获取进程级 ClickHouse 客户端(单例)"""
|
||||
global _process_ch_client
|
||||
if _process_ch_client is None:
|
||||
_process_ch_client = Client(**CLICKHOUSE_CONFIG)
|
||||
return _process_ch_client
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def code_to_ch_format(code: str) -> str:
|
||||
if not code or len(code) != 6 or not code.isdigit():
|
||||
return None
|
||||
if code.startswith('6'):
|
||||
return f"{code}.SH"
|
||||
elif code.startswith('0') or code.startswith('3'):
|
||||
return f"{code}.SZ"
|
||||
else:
|
||||
return f"{code}.BJ"
|
||||
|
||||
|
||||
def time_to_slot(ts) -> str:
|
||||
"""将时间戳转换为时间片(HH:MM格式)"""
|
||||
if isinstance(ts, str):
|
||||
return ts
|
||||
return ts.strftime('%H:%M')
|
||||
|
||||
|
||||
# ==================== 获取概念列表 ====================
|
||||
|
||||
def get_all_concepts() -> List[dict]:
|
||||
"""从ES获取所有叶子概念"""
|
||||
es_client = get_es_client()
|
||||
concepts = []
|
||||
|
||||
query = {
|
||||
"query": {"match_all": {}},
|
||||
"size": 100,
|
||||
"_source": ["concept_id", "concept", "stocks"]
|
||||
}
|
||||
|
||||
resp = es_client.search(index=ES_INDEX, body=query, scroll='2m')
|
||||
scroll_id = resp['_scroll_id']
|
||||
hits = resp['hits']['hits']
|
||||
|
||||
while len(hits) > 0:
|
||||
for hit in hits:
|
||||
source = hit['_source']
|
||||
stocks = []
|
||||
if 'stocks' in source and isinstance(source['stocks'], list):
|
||||
for stock in source['stocks']:
|
||||
if isinstance(stock, dict) and 'code' in stock and stock['code']:
|
||||
stocks.append(stock['code'])
|
||||
|
||||
if stocks:
|
||||
concepts.append({
|
||||
'concept_id': source.get('concept_id'),
|
||||
'concept_name': source.get('concept'),
|
||||
'stocks': stocks
|
||||
})
|
||||
|
||||
resp = es_client.scroll(scroll_id=scroll_id, scroll='2m')
|
||||
scroll_id = resp['_scroll_id']
|
||||
hits = resp['hits']['hits']
|
||||
|
||||
es_client.clear_scroll(scroll_id=scroll_id)
|
||||
print(f"获取到 {len(concepts)} 个概念")
|
||||
return concepts
|
||||
|
||||
|
||||
# ==================== 获取交易日列表 ====================
|
||||
|
||||
def get_trading_days(start_date: str, end_date: str) -> List[str]:
|
||||
"""获取交易日列表"""
|
||||
client = get_ch_client()
|
||||
|
||||
query = f"""
|
||||
SELECT DISTINCT toDate(timestamp) as trade_date
|
||||
FROM stock_minute
|
||||
WHERE toDate(timestamp) >= '{start_date}'
|
||||
AND toDate(timestamp) <= '{end_date}'
|
||||
ORDER BY trade_date
|
||||
"""
|
||||
|
||||
result = client.execute(query)
|
||||
days = [row[0].strftime('%Y-%m-%d') for row in result]
|
||||
if days:
|
||||
print(f"找到 {len(days)} 个交易日: {days[0]} ~ {days[-1]}")
|
||||
return days
|
||||
|
||||
|
||||
# ==================== 获取昨收价 ====================
|
||||
|
||||
def get_prev_close(stock_codes: List[str], trade_date: str) -> Dict[str, float]:
|
||||
"""获取昨收价(上一交易日的收盘价 F007N)"""
|
||||
valid_codes = [c for c in stock_codes if c and len(c) == 6 and c.isdigit()]
|
||||
if not valid_codes:
|
||||
return {}
|
||||
|
||||
codes_str = "','".join(valid_codes)
|
||||
query = f"""
|
||||
SELECT SECCODE, F007N
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ('{codes_str}')
|
||||
AND TRADEDATE = (
|
||||
SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}'
|
||||
)
|
||||
AND F007N IS NOT NULL AND F007N > 0
|
||||
"""
|
||||
|
||||
try:
|
||||
engine = get_mysql_engine()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(query))
|
||||
return {row[0]: float(row[1]) for row in result if row[1]}
|
||||
except Exception as e:
|
||||
print(f"获取昨收价失败: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_index_prev_close(trade_date: str, index_code: str = REFERENCE_INDEX) -> float:
|
||||
"""获取指数昨收价"""
|
||||
code_no_suffix = index_code.split('.')[0]
|
||||
|
||||
try:
|
||||
engine = get_mysql_engine()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("""
|
||||
SELECT F006N FROM ea_exchangetrade
|
||||
WHERE INDEXCODE = :code AND TRADEDATE < :today
|
||||
ORDER BY TRADEDATE DESC LIMIT 1
|
||||
"""), {'code': code_no_suffix, 'today': trade_date}).fetchone()
|
||||
|
||||
if result and result[0]:
|
||||
return float(result[0])
|
||||
except Exception as e:
|
||||
print(f"获取指数昨收失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ==================== 获取分钟数据 ====================
|
||||
|
||||
def get_daily_stock_data(trade_date: str, stock_codes: List[str]) -> pd.DataFrame:
|
||||
"""获取单日所有股票的分钟数据"""
|
||||
client = get_ch_client()
|
||||
|
||||
ch_codes = []
|
||||
code_map = {}
|
||||
for code in stock_codes:
|
||||
ch_code = code_to_ch_format(code)
|
||||
if ch_code:
|
||||
ch_codes.append(ch_code)
|
||||
code_map[ch_code] = code
|
||||
|
||||
if not ch_codes:
|
||||
return pd.DataFrame()
|
||||
|
||||
ch_codes_str = "','".join(ch_codes)
|
||||
|
||||
query = f"""
|
||||
SELECT code, timestamp, close, volume, amt
|
||||
FROM stock_minute
|
||||
WHERE toDate(timestamp) = '{trade_date}'
|
||||
AND code IN ('{ch_codes_str}')
|
||||
ORDER BY code, timestamp
|
||||
"""
|
||||
|
||||
result = client.execute(query)
|
||||
if not result:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(result, columns=['ch_code', 'timestamp', 'close', 'volume', 'amt'])
|
||||
df['code'] = df['ch_code'].map(code_map)
|
||||
df = df.dropna(subset=['code'])
|
||||
|
||||
return df[['code', 'timestamp', 'close', 'volume', 'amt']]
|
||||
|
||||
|
||||
def get_daily_index_data(trade_date: str, index_code: str = REFERENCE_INDEX) -> pd.DataFrame:
|
||||
"""获取单日指数分钟数据"""
|
||||
client = get_ch_client()
|
||||
|
||||
query = f"""
|
||||
SELECT timestamp, close, volume, amt
|
||||
FROM index_minute
|
||||
WHERE toDate(timestamp) = '{trade_date}'
|
||||
AND code = '{index_code}'
|
||||
ORDER BY timestamp
|
||||
"""
|
||||
|
||||
result = client.execute(query)
|
||||
if not result:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(result, columns=['timestamp', 'close', 'volume', 'amt'])
|
||||
return df
|
||||
|
||||
|
||||
# ==================== 计算原始概念特征(单日)====================
|
||||
|
||||
def compute_raw_concept_features(
|
||||
trade_date: str,
|
||||
concepts: List[dict],
|
||||
all_stocks: List[str]
|
||||
) -> pd.DataFrame:
|
||||
"""计算单日概念的原始特征(alpha, amt, rank_pct, limit_up_ratio)"""
|
||||
# 检查缓存
|
||||
cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet')
|
||||
if os.path.exists(cache_file):
|
||||
return pd.read_parquet(cache_file)
|
||||
|
||||
# 获取数据
|
||||
stock_df = get_daily_stock_data(trade_date, all_stocks)
|
||||
if stock_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
index_df = get_daily_index_data(trade_date)
|
||||
if index_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 获取昨收价
|
||||
prev_close = get_prev_close(all_stocks, trade_date)
|
||||
index_prev_close = get_index_prev_close(trade_date)
|
||||
|
||||
if not prev_close or not index_prev_close:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 计算涨跌幅
|
||||
stock_df['prev_close'] = stock_df['code'].map(prev_close)
|
||||
stock_df = stock_df.dropna(subset=['prev_close'])
|
||||
stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100
|
||||
|
||||
index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100
|
||||
index_change_map = dict(zip(index_df['timestamp'], index_df['change_pct']))
|
||||
|
||||
# 获取所有时间点
|
||||
timestamps = sorted(stock_df['timestamp'].unique())
|
||||
|
||||
# 概念到股票的映射
|
||||
concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts}
|
||||
|
||||
results = []
|
||||
|
||||
for ts in timestamps:
|
||||
ts_stock_data = stock_df[stock_df['timestamp'] == ts]
|
||||
index_change = index_change_map.get(ts, 0)
|
||||
|
||||
stock_change = dict(zip(ts_stock_data['code'], ts_stock_data['change_pct']))
|
||||
stock_amt = dict(zip(ts_stock_data['code'], ts_stock_data['amt']))
|
||||
|
||||
concept_features = []
|
||||
|
||||
for concept_id, stocks in concept_stocks.items():
|
||||
concept_changes = [stock_change[s] for s in stocks if s in stock_change]
|
||||
concept_amts = [stock_amt.get(s, 0) for s in stocks if s in stock_change]
|
||||
|
||||
if not concept_changes:
|
||||
continue
|
||||
|
||||
avg_change = np.mean(concept_changes)
|
||||
total_amt = sum(concept_amts)
|
||||
alpha = avg_change - index_change
|
||||
|
||||
limit_up_count = sum(1 for c in concept_changes if c >= CONFIG['limit_up_threshold'])
|
||||
limit_up_ratio = limit_up_count / len(concept_changes)
|
||||
|
||||
concept_features.append({
|
||||
'concept_id': concept_id,
|
||||
'alpha': alpha,
|
||||
'total_amt': total_amt,
|
||||
'limit_up_ratio': limit_up_ratio,
|
||||
'stock_count': len(concept_changes),
|
||||
})
|
||||
|
||||
if not concept_features:
|
||||
continue
|
||||
|
||||
concept_df = pd.DataFrame(concept_features)
|
||||
concept_df['rank_pct'] = concept_df['alpha'].rank(pct=True)
|
||||
concept_df['timestamp'] = ts
|
||||
concept_df['time_slot'] = time_to_slot(ts)
|
||||
concept_df['trade_date'] = trade_date
|
||||
|
||||
results.append(concept_df)
|
||||
|
||||
if not results:
|
||||
return pd.DataFrame()
|
||||
|
||||
result_df = pd.concat(results, ignore_index=True)
|
||||
|
||||
# 保存缓存
|
||||
result_df.to_parquet(cache_file, index=False)
|
||||
|
||||
return result_df
|
||||
|
||||
|
||||
# ==================== 滚动窗口基线计算 ====================
|
||||
|
||||
def compute_rolling_baseline(
|
||||
historical_data: pd.DataFrame,
|
||||
concept_id: str
|
||||
) -> Dict[str, Dict]:
|
||||
"""
|
||||
计算单个概念的滚动基线
|
||||
|
||||
返回: {time_slot: {alpha_mean, alpha_std, amt_mean, amt_std, rank_mean, rank_std, sample_count}}
|
||||
"""
|
||||
if historical_data.empty:
|
||||
return {}
|
||||
|
||||
concept_data = historical_data[historical_data['concept_id'] == concept_id]
|
||||
if concept_data.empty:
|
||||
return {}
|
||||
|
||||
baseline_dict = {}
|
||||
|
||||
for time_slot, group in concept_data.groupby('time_slot'):
|
||||
if len(group) < CONFIG['min_baseline_samples']:
|
||||
continue
|
||||
|
||||
alpha_std = group['alpha'].std()
|
||||
amt_std = group['total_amt'].std()
|
||||
rank_std = group['rank_pct'].std()
|
||||
|
||||
baseline_dict[time_slot] = {
|
||||
'alpha_mean': group['alpha'].mean(),
|
||||
'alpha_std': max(alpha_std if pd.notna(alpha_std) else 1.0, 0.1),
|
||||
'amt_mean': group['total_amt'].mean(),
|
||||
'amt_std': max(amt_std if pd.notna(amt_std) else group['total_amt'].mean() * 0.5, 1.0),
|
||||
'rank_mean': group['rank_pct'].mean(),
|
||||
'rank_std': max(rank_std if pd.notna(rank_std) else 0.2, 0.05),
|
||||
'sample_count': len(group),
|
||||
}
|
||||
|
||||
return baseline_dict
|
||||
|
||||
|
||||
# ==================== 计算单日 Z-Score 特征(带滚动基线)====================
|
||||
|
||||
def compute_zscore_features_rolling(
|
||||
trade_date: str,
|
||||
concepts: List[dict],
|
||||
all_stocks: List[str],
|
||||
historical_raw_data: pd.DataFrame # 该日期之前 N 天的原始数据
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
计算单日的 Z-Score 特征(使用滚动窗口基线)
|
||||
|
||||
关键改进:
|
||||
1. 基线只使用 trade_date 之前的数据(无未来泄露)
|
||||
2. 动量基于 Z-Score 计算(消除波动率异构性)
|
||||
"""
|
||||
# 计算当日原始特征
|
||||
raw_df = compute_raw_concept_features(trade_date, concepts, all_stocks)
|
||||
|
||||
if raw_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
zscore_records = []
|
||||
|
||||
for concept_id, group in raw_df.groupby('concept_id'):
|
||||
# 计算该概念的滚动基线(只用历史数据)
|
||||
baseline_dict = compute_rolling_baseline(historical_raw_data, concept_id)
|
||||
|
||||
if not baseline_dict:
|
||||
continue
|
||||
|
||||
# 按时间排序
|
||||
group = group.sort_values('timestamp').reset_index(drop=True)
|
||||
|
||||
# Z-Score 历史(用于计算基于 Z-Score 的动量)
|
||||
zscore_history = []
|
||||
|
||||
for idx, row in group.iterrows():
|
||||
time_slot = row['time_slot']
|
||||
|
||||
if time_slot not in baseline_dict:
|
||||
continue
|
||||
|
||||
bl = baseline_dict[time_slot]
|
||||
|
||||
# 计算 Z-Score
|
||||
alpha_zscore = (row['alpha'] - bl['alpha_mean']) / bl['alpha_std']
|
||||
amt_zscore = (row['total_amt'] - bl['amt_mean']) / bl['amt_std']
|
||||
rank_zscore = (row['rank_pct'] - bl['rank_mean']) / bl['rank_std']
|
||||
|
||||
# 截断极端值
|
||||
clip = CONFIG['zscore_clip']
|
||||
alpha_zscore = np.clip(alpha_zscore, -clip, clip)
|
||||
amt_zscore = np.clip(amt_zscore, -clip, clip)
|
||||
rank_zscore = np.clip(rank_zscore, -clip, clip)
|
||||
|
||||
# 记录 Z-Score 历史
|
||||
zscore_history.append(alpha_zscore)
|
||||
|
||||
# 基于 Z-Score 计算动量(消除波动率异构性)
|
||||
momentum_3m = 0.0
|
||||
momentum_5m = 0.0
|
||||
|
||||
if len(zscore_history) >= 3:
|
||||
recent_3 = zscore_history[-3:]
|
||||
older_3 = zscore_history[-6:-3] if len(zscore_history) >= 6 else [zscore_history[0]]
|
||||
momentum_3m = np.mean(recent_3) - np.mean(older_3)
|
||||
|
||||
if len(zscore_history) >= 5:
|
||||
recent_5 = zscore_history[-5:]
|
||||
older_5 = zscore_history[-10:-5] if len(zscore_history) >= 10 else [zscore_history[0]]
|
||||
momentum_5m = np.mean(recent_5) - np.mean(older_5)
|
||||
|
||||
zscore_records.append({
|
||||
'concept_id': concept_id,
|
||||
'timestamp': row['timestamp'],
|
||||
'time_slot': time_slot,
|
||||
'trade_date': trade_date,
|
||||
# 原始特征
|
||||
'alpha': row['alpha'],
|
||||
'total_amt': row['total_amt'],
|
||||
'limit_up_ratio': row['limit_up_ratio'],
|
||||
'stock_count': row['stock_count'],
|
||||
'rank_pct': row['rank_pct'],
|
||||
# Z-Score 特征
|
||||
'alpha_zscore': alpha_zscore,
|
||||
'amt_zscore': amt_zscore,
|
||||
'rank_zscore': rank_zscore,
|
||||
# 基于 Z-Score 的动量
|
||||
'momentum_3m': momentum_3m,
|
||||
'momentum_5m': momentum_5m,
|
||||
})
|
||||
|
||||
if not zscore_records:
|
||||
return pd.DataFrame()
|
||||
|
||||
return pd.DataFrame(zscore_records)
|
||||
|
||||
|
||||
# ==================== 多进程处理 ====================
|
||||
|
||||
def process_single_day_v2(args) -> Tuple[str, bool]:
|
||||
"""处理单个交易日(多进程版本)"""
|
||||
trade_date, day_index, concepts, all_stocks, all_trading_days = args
|
||||
output_file = os.path.join(OUTPUT_DIR, f'features_v2_{trade_date}.parquet')
|
||||
|
||||
if os.path.exists(output_file):
|
||||
return (trade_date, True)
|
||||
|
||||
try:
|
||||
# 计算滚动窗口范围(该日期之前的 N 天)
|
||||
baseline_days = CONFIG['baseline_days']
|
||||
|
||||
# 找出 trade_date 之前的交易日
|
||||
start_idx = max(0, day_index - baseline_days)
|
||||
end_idx = day_index # 不包含当天
|
||||
|
||||
if end_idx <= start_idx:
|
||||
# 没有足够的历史数据
|
||||
return (trade_date, False)
|
||||
|
||||
historical_days = all_trading_days[start_idx:end_idx]
|
||||
|
||||
# 加载历史原始数据
|
||||
historical_dfs = []
|
||||
for hist_date in historical_days:
|
||||
cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{hist_date}.parquet')
|
||||
if os.path.exists(cache_file):
|
||||
historical_dfs.append(pd.read_parquet(cache_file))
|
||||
else:
|
||||
# 需要计算
|
||||
hist_df = compute_raw_concept_features(hist_date, concepts, all_stocks)
|
||||
if not hist_df.empty:
|
||||
historical_dfs.append(hist_df)
|
||||
|
||||
if not historical_dfs:
|
||||
return (trade_date, False)
|
||||
|
||||
historical_raw_data = pd.concat(historical_dfs, ignore_index=True)
|
||||
|
||||
# 计算当日 Z-Score 特征(使用滚动基线)
|
||||
df = compute_zscore_features_rolling(trade_date, concepts, all_stocks, historical_raw_data)
|
||||
|
||||
if df.empty:
|
||||
return (trade_date, False)
|
||||
|
||||
df.to_parquet(output_file, index=False)
|
||||
return (trade_date, True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{trade_date}] 处理失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return (trade_date, False)
|
||||
|
||||
|
||||
# ==================== 主流程 ====================
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='准备训练数据 V2(滚动窗口基线 + Z-Score 动量)')
|
||||
parser.add_argument('--start', type=str, default='2022-01-01', help='开始日期')
|
||||
parser.add_argument('--end', type=str, default=None, help='结束日期(默认今天)')
|
||||
parser.add_argument('--workers', type=int, default=18, help='并行进程数')
|
||||
parser.add_argument('--baseline-days', type=int, default=20, help='滚动基线窗口大小')
|
||||
parser.add_argument('--force', action='store_true', help='强制重新计算(忽略缓存)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
end_date = args.end or datetime.now().strftime('%Y-%m-%d')
|
||||
CONFIG['baseline_days'] = args.baseline_days
|
||||
|
||||
print("=" * 60)
|
||||
print("数据准备 V2 - 滚动窗口基线 + Z-Score 动量")
|
||||
print("=" * 60)
|
||||
print(f"日期范围: {args.start} ~ {end_date}")
|
||||
print(f"并行进程数: {args.workers}")
|
||||
print(f"滚动基线窗口: {args.baseline_days} 天")
|
||||
|
||||
# 初始化主进程连接
|
||||
init_process_connections()
|
||||
|
||||
# 1. 获取概念列表
|
||||
concepts = get_all_concepts()
|
||||
all_stocks = list(set(s for c in concepts for s in c['stocks']))
|
||||
print(f"股票总数: {len(all_stocks)}")
|
||||
|
||||
# 2. 获取交易日列表
|
||||
trading_days = get_trading_days(args.start, end_date)
|
||||
|
||||
if not trading_days:
|
||||
print("无交易日数据")
|
||||
return
|
||||
|
||||
# 3. 第一阶段:预计算所有原始特征(用于缓存)
|
||||
print(f"\n{'='*60}")
|
||||
print("第一阶段:预计算原始特征(用于滚动基线)")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# 如果强制重新计算,删除缓存
|
||||
if args.force:
|
||||
import shutil
|
||||
if os.path.exists(RAW_CACHE_DIR):
|
||||
shutil.rmtree(RAW_CACHE_DIR)
|
||||
os.makedirs(RAW_CACHE_DIR, exist_ok=True)
|
||||
if os.path.exists(OUTPUT_DIR):
|
||||
for f in os.listdir(OUTPUT_DIR):
|
||||
if f.startswith('features_v2_'):
|
||||
os.remove(os.path.join(OUTPUT_DIR, f))
|
||||
|
||||
# 单线程预计算原始特征(因为需要顺序缓存)
|
||||
print(f"预计算 {len(trading_days)} 天的原始特征...")
|
||||
for trade_date in tqdm(trading_days, desc="预计算原始特征"):
|
||||
cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet')
|
||||
if not os.path.exists(cache_file):
|
||||
compute_raw_concept_features(trade_date, concepts, all_stocks)
|
||||
|
||||
# 4. 第二阶段:计算 Z-Score 特征(多进程)
|
||||
print(f"\n{'='*60}")
|
||||
print("第二阶段:计算 Z-Score 特征(滚动基线)")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# 从第 baseline_days 天开始(前面的没有足够历史)
|
||||
start_idx = args.baseline_days
|
||||
processable_days = trading_days[start_idx:]
|
||||
|
||||
if not processable_days:
|
||||
print(f"错误:需要至少 {args.baseline_days + 1} 天的数据")
|
||||
return
|
||||
|
||||
print(f"可处理日期: {processable_days[0]} ~ {processable_days[-1]} ({len(processable_days)} 天)")
|
||||
print(f"跳过前 {start_idx} 天(基线预热期)")
|
||||
|
||||
# 构建任务
|
||||
tasks = []
|
||||
for i, trade_date in enumerate(trading_days):
|
||||
if i >= start_idx:
|
||||
tasks.append((trade_date, i, concepts, all_stocks, trading_days))
|
||||
|
||||
print(f"开始处理 {len(tasks)} 个交易日({args.workers} 进程并行)...")
|
||||
|
||||
success_count = 0
|
||||
failed_dates = []
|
||||
|
||||
# 使用进程池初始化器
|
||||
with ProcessPoolExecutor(max_workers=args.workers, initializer=init_process_connections) as executor:
|
||||
futures = {executor.submit(process_single_day_v2, task): task[0] for task in tasks}
|
||||
|
||||
with tqdm(total=len(futures), desc="处理进度", unit="天") as pbar:
|
||||
for future in as_completed(futures):
|
||||
trade_date = futures[future]
|
||||
try:
|
||||
result_date, success = future.result()
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
failed_dates.append(result_date)
|
||||
except Exception as e:
|
||||
print(f"\n[{trade_date}] 进程异常: {e}")
|
||||
failed_dates.append(trade_date)
|
||||
pbar.update(1)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"处理完成: {success_count}/{len(tasks)} 个交易日")
|
||||
if failed_dates:
|
||||
print(f"失败日期: {failed_dates[:10]}{'...' if len(failed_dates) > 10 else ''}")
|
||||
print(f"数据保存在: {OUTPUT_DIR}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,729 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
V2 实时异动检测器
|
||||
|
||||
使用方法:
|
||||
# 作为模块导入
|
||||
from ml.realtime_detector_v2 import RealtimeDetectorV2
|
||||
|
||||
detector = RealtimeDetectorV2()
|
||||
alerts = detector.detect_realtime() # 检测当前时刻
|
||||
|
||||
# 或命令行测试
|
||||
python ml/realtime_detector_v2.py --date 2025-12-09
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pickle
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from collections import defaultdict, deque
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
from sqlalchemy import create_engine, text
|
||||
from elasticsearch import Elasticsearch
|
||||
from clickhouse_driver import Client
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from ml.model import TransformerAutoencoder
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
MYSQL_URL = "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock"
|
||||
ES_HOST = 'http://127.0.0.1:9200'
|
||||
ES_INDEX = 'concept_library_v3'
|
||||
|
||||
CLICKHOUSE_CONFIG = {
|
||||
'host': '127.0.0.1',
|
||||
'port': 9000,
|
||||
'user': 'default',
|
||||
'password': 'Zzl33818!',
|
||||
'database': 'stock'
|
||||
}
|
||||
|
||||
REFERENCE_INDEX = '000001.SH'
|
||||
BASELINE_FILE = 'ml/data_v2/baselines/realtime_baseline.pkl'
|
||||
MODEL_DIR = 'ml/checkpoints_v2'
|
||||
|
||||
# 检测配置
|
||||
CONFIG = {
|
||||
'seq_len': 10, # LSTM 序列长度
|
||||
'confirm_window': 5, # 持续确认窗口
|
||||
'confirm_ratio': 0.6, # 确认比例
|
||||
'rule_weight': 0.5,
|
||||
'ml_weight': 0.5,
|
||||
'rule_trigger': 60,
|
||||
'ml_trigger': 70,
|
||||
'fusion_trigger': 50,
|
||||
'cooldown_minutes': 10,
|
||||
'max_alerts_per_minute': 15,
|
||||
'zscore_clip': 5.0,
|
||||
'limit_up_threshold': 9.8,
|
||||
}
|
||||
|
||||
FEATURES = ['alpha_zscore', 'amt_zscore', 'rank_zscore', 'momentum_3m', 'momentum_5m', 'limit_up_ratio']
|
||||
|
||||
|
||||
# ==================== 数据库连接 ====================
|
||||
|
||||
_mysql_engine = None
|
||||
_es_client = None
|
||||
_ch_client = None
|
||||
|
||||
|
||||
def get_mysql_engine():
|
||||
global _mysql_engine
|
||||
if _mysql_engine is None:
|
||||
_mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True)
|
||||
return _mysql_engine
|
||||
|
||||
|
||||
def get_es_client():
|
||||
global _es_client
|
||||
if _es_client is None:
|
||||
_es_client = Elasticsearch([ES_HOST])
|
||||
return _es_client
|
||||
|
||||
|
||||
def get_ch_client():
|
||||
global _ch_client
|
||||
if _ch_client is None:
|
||||
_ch_client = Client(**CLICKHOUSE_CONFIG)
|
||||
return _ch_client
|
||||
|
||||
|
||||
def code_to_ch_format(code: str) -> str:
|
||||
if not code or len(code) != 6 or not code.isdigit():
|
||||
return None
|
||||
if code.startswith('6'):
|
||||
return f"{code}.SH"
|
||||
elif code.startswith('0') or code.startswith('3'):
|
||||
return f"{code}.SZ"
|
||||
return f"{code}.BJ"
|
||||
|
||||
|
||||
def time_to_slot(ts) -> str:
|
||||
if isinstance(ts, str):
|
||||
return ts
|
||||
return ts.strftime('%H:%M')
|
||||
|
||||
|
||||
# ==================== 规则评分 ====================
|
||||
|
||||
def score_rules_zscore(features: Dict) -> Tuple[float, List[str]]:
|
||||
"""基于 Z-Score 的规则评分"""
|
||||
score = 0.0
|
||||
triggered = []
|
||||
|
||||
alpha_z = abs(features.get('alpha_zscore', 0))
|
||||
amt_z = features.get('amt_zscore', 0)
|
||||
rank_z = abs(features.get('rank_zscore', 0))
|
||||
mom_3m = features.get('momentum_3m', 0)
|
||||
mom_5m = features.get('momentum_5m', 0)
|
||||
limit_up = features.get('limit_up_ratio', 0)
|
||||
|
||||
# Alpha Z-Score
|
||||
if alpha_z >= 4.0:
|
||||
score += 25
|
||||
triggered.append('alpha_extreme')
|
||||
elif alpha_z >= 3.0:
|
||||
score += 18
|
||||
triggered.append('alpha_strong')
|
||||
elif alpha_z >= 2.0:
|
||||
score += 10
|
||||
triggered.append('alpha_moderate')
|
||||
|
||||
# 成交额 Z-Score
|
||||
if amt_z >= 4.0:
|
||||
score += 20
|
||||
triggered.append('amt_extreme')
|
||||
elif amt_z >= 3.0:
|
||||
score += 12
|
||||
triggered.append('amt_strong')
|
||||
elif amt_z >= 2.0:
|
||||
score += 6
|
||||
triggered.append('amt_moderate')
|
||||
|
||||
# 排名 Z-Score
|
||||
if rank_z >= 3.0:
|
||||
score += 15
|
||||
triggered.append('rank_extreme')
|
||||
elif rank_z >= 2.0:
|
||||
score += 8
|
||||
triggered.append('rank_strong')
|
||||
|
||||
# 动量(基于 Z-Score 的)
|
||||
if mom_3m >= 1.0:
|
||||
score += 12
|
||||
triggered.append('momentum_3m_strong')
|
||||
elif mom_3m >= 0.5:
|
||||
score += 6
|
||||
triggered.append('momentum_3m_moderate')
|
||||
|
||||
if mom_5m >= 1.5:
|
||||
score += 10
|
||||
triggered.append('momentum_5m_strong')
|
||||
|
||||
# 涨停比例
|
||||
if limit_up >= 0.3:
|
||||
score += 20
|
||||
triggered.append('limit_up_extreme')
|
||||
elif limit_up >= 0.15:
|
||||
score += 12
|
||||
triggered.append('limit_up_strong')
|
||||
elif limit_up >= 0.08:
|
||||
score += 5
|
||||
triggered.append('limit_up_moderate')
|
||||
|
||||
# 组合规则
|
||||
if alpha_z >= 2.0 and amt_z >= 2.0:
|
||||
score += 15
|
||||
triggered.append('combo_alpha_amt')
|
||||
|
||||
if alpha_z >= 2.0 and limit_up >= 0.1:
|
||||
score += 12
|
||||
triggered.append('combo_alpha_limitup')
|
||||
|
||||
return min(score, 100), triggered
|
||||
|
||||
|
||||
# ==================== 实时检测器 ====================
|
||||
|
||||
class RealtimeDetectorV2:
|
||||
"""V2 实时异动检测器"""
|
||||
|
||||
def __init__(self, model_dir: str = MODEL_DIR, baseline_file: str = BASELINE_FILE):
|
||||
print("初始化 V2 实时检测器...")
|
||||
|
||||
# 加载概念
|
||||
self.concepts = self._load_concepts()
|
||||
self.concept_stocks = {c['concept_id']: set(c['stocks']) for c in self.concepts}
|
||||
self.all_stocks = list(set(s for c in self.concepts for s in c['stocks']))
|
||||
|
||||
# 加载基线
|
||||
self.baselines = self._load_baselines(baseline_file)
|
||||
|
||||
# 加载模型
|
||||
self.model, self.thresholds, self.device = self._load_model(model_dir)
|
||||
|
||||
# 状态管理
|
||||
self.zscore_history = defaultdict(lambda: deque(maxlen=CONFIG['seq_len']))
|
||||
self.anomaly_candidates = defaultdict(lambda: deque(maxlen=CONFIG['confirm_window']))
|
||||
self.cooldown = {}
|
||||
|
||||
print(f"初始化完成: {len(self.concepts)} 概念, {len(self.baselines)} 基线")
|
||||
|
||||
def _load_concepts(self) -> List[dict]:
|
||||
"""从 ES 加载概念"""
|
||||
es = get_es_client()
|
||||
concepts = []
|
||||
|
||||
query = {"query": {"match_all": {}}, "size": 100, "_source": ["concept_id", "concept", "stocks"]}
|
||||
resp = es.search(index=ES_INDEX, body=query, scroll='2m')
|
||||
scroll_id = resp['_scroll_id']
|
||||
hits = resp['hits']['hits']
|
||||
|
||||
while hits:
|
||||
for hit in hits:
|
||||
src = hit['_source']
|
||||
stocks = [s['code'] for s in src.get('stocks', []) if isinstance(s, dict) and s.get('code')]
|
||||
if stocks:
|
||||
concepts.append({
|
||||
'concept_id': src.get('concept_id'),
|
||||
'concept_name': src.get('concept'),
|
||||
'stocks': stocks
|
||||
})
|
||||
resp = es.scroll(scroll_id=scroll_id, scroll='2m')
|
||||
scroll_id = resp['_scroll_id']
|
||||
hits = resp['hits']['hits']
|
||||
|
||||
es.clear_scroll(scroll_id=scroll_id)
|
||||
return concepts
|
||||
|
||||
def _load_baselines(self, baseline_file: str) -> Dict:
|
||||
"""加载基线"""
|
||||
if not os.path.exists(baseline_file):
|
||||
print(f"警告: 基线文件不存在: {baseline_file}")
|
||||
print("请先运行: python ml/update_baseline.py")
|
||||
return {}
|
||||
|
||||
with open(baseline_file, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
print(f"基线日期范围: {data.get('date_range', 'unknown')}")
|
||||
print(f"更新时间: {data.get('update_time', 'unknown')}")
|
||||
|
||||
return data.get('baselines', {})
|
||||
|
||||
def _load_model(self, model_dir: str):
|
||||
"""加载模型"""
|
||||
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
|
||||
config_path = os.path.join(model_dir, 'config.json')
|
||||
model_path = os.path.join(model_dir, 'best_model.pt')
|
||||
threshold_path = os.path.join(model_dir, 'thresholds.json')
|
||||
|
||||
if not os.path.exists(model_path):
|
||||
print(f"警告: 模型不存在: {model_path}")
|
||||
return None, {}, device
|
||||
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
model = TransformerAutoencoder(**config['model'])
|
||||
checkpoint = torch.load(model_path, map_location=device)
|
||||
model.load_state_dict(checkpoint['model_state_dict'])
|
||||
model.to(device)
|
||||
model.eval()
|
||||
|
||||
thresholds = {}
|
||||
if os.path.exists(threshold_path):
|
||||
with open(threshold_path) as f:
|
||||
thresholds = json.load(f)
|
||||
|
||||
print(f"模型已加载: {model_path}")
|
||||
return model, thresholds, device
|
||||
|
||||
def _get_realtime_data(self, trade_date: str) -> pd.DataFrame:
|
||||
"""获取实时数据并计算原始特征"""
|
||||
ch = get_ch_client()
|
||||
|
||||
# 获取股票数据
|
||||
ch_codes = [code_to_ch_format(c) for c in self.all_stocks if code_to_ch_format(c)]
|
||||
ch_codes_str = "','".join(ch_codes)
|
||||
|
||||
stock_query = f"""
|
||||
SELECT code, timestamp, close, amt
|
||||
FROM stock_minute
|
||||
WHERE toDate(timestamp) = '{trade_date}'
|
||||
AND code IN ('{ch_codes_str}')
|
||||
ORDER BY timestamp
|
||||
"""
|
||||
stock_result = ch.execute(stock_query)
|
||||
if not stock_result:
|
||||
return pd.DataFrame()
|
||||
|
||||
stock_df = pd.DataFrame(stock_result, columns=['ch_code', 'timestamp', 'close', 'amt'])
|
||||
|
||||
# 映射回原始代码
|
||||
ch_to_code = {code_to_ch_format(c): c for c in self.all_stocks if code_to_ch_format(c)}
|
||||
stock_df['code'] = stock_df['ch_code'].map(ch_to_code)
|
||||
stock_df = stock_df.dropna(subset=['code'])
|
||||
|
||||
# 获取指数数据
|
||||
index_query = f"""
|
||||
SELECT timestamp, close
|
||||
FROM index_minute
|
||||
WHERE toDate(timestamp) = '{trade_date}'
|
||||
AND code = '{REFERENCE_INDEX}'
|
||||
ORDER BY timestamp
|
||||
"""
|
||||
index_result = ch.execute(index_query)
|
||||
if not index_result:
|
||||
return pd.DataFrame()
|
||||
|
||||
index_df = pd.DataFrame(index_result, columns=['timestamp', 'close'])
|
||||
|
||||
# 获取昨收价
|
||||
engine = get_mysql_engine()
|
||||
codes_str = "','".join([c for c in self.all_stocks if c and len(c) == 6])
|
||||
|
||||
with engine.connect() as conn:
|
||||
prev_result = conn.execute(text(f"""
|
||||
SELECT SECCODE, F007N FROM ea_trade
|
||||
WHERE SECCODE IN ('{codes_str}')
|
||||
AND TRADEDATE = (SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}')
|
||||
AND F007N > 0
|
||||
"""))
|
||||
prev_close = {row[0]: float(row[1]) for row in prev_result if row[1]}
|
||||
|
||||
idx_result = conn.execute(text("""
|
||||
SELECT F006N FROM ea_exchangetrade
|
||||
WHERE INDEXCODE = '000001' AND TRADEDATE < :today
|
||||
ORDER BY TRADEDATE DESC LIMIT 1
|
||||
"""), {'today': trade_date}).fetchone()
|
||||
index_prev_close = float(idx_result[0]) if idx_result else None
|
||||
|
||||
if not prev_close or not index_prev_close:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 计算涨跌幅
|
||||
stock_df['prev_close'] = stock_df['code'].map(prev_close)
|
||||
stock_df = stock_df.dropna(subset=['prev_close'])
|
||||
stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100
|
||||
|
||||
index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100
|
||||
index_map = dict(zip(index_df['timestamp'], index_df['change_pct']))
|
||||
|
||||
# 按时间聚合概念特征
|
||||
results = []
|
||||
for ts in sorted(stock_df['timestamp'].unique()):
|
||||
ts_data = stock_df[stock_df['timestamp'] == ts]
|
||||
idx_chg = index_map.get(ts, 0)
|
||||
|
||||
stock_chg = dict(zip(ts_data['code'], ts_data['change_pct']))
|
||||
stock_amt = dict(zip(ts_data['code'], ts_data['amt']))
|
||||
|
||||
for cid, stocks in self.concept_stocks.items():
|
||||
changes = [stock_chg[s] for s in stocks if s in stock_chg]
|
||||
amts = [stock_amt.get(s, 0) for s in stocks if s in stock_chg]
|
||||
|
||||
if not changes:
|
||||
continue
|
||||
|
||||
alpha = np.mean(changes) - idx_chg
|
||||
total_amt = sum(amts)
|
||||
limit_up_ratio = sum(1 for c in changes if c >= CONFIG['limit_up_threshold']) / len(changes)
|
||||
|
||||
results.append({
|
||||
'concept_id': cid,
|
||||
'timestamp': ts,
|
||||
'time_slot': time_to_slot(ts),
|
||||
'alpha': alpha,
|
||||
'total_amt': total_amt,
|
||||
'limit_up_ratio': limit_up_ratio,
|
||||
'stock_count': len(changes),
|
||||
})
|
||||
|
||||
if not results:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(results)
|
||||
|
||||
# 计算排名
|
||||
for ts in df['timestamp'].unique():
|
||||
mask = df['timestamp'] == ts
|
||||
df.loc[mask, 'rank_pct'] = df.loc[mask, 'alpha'].rank(pct=True)
|
||||
|
||||
return df
|
||||
|
||||
def _compute_zscore(self, concept_id: str, time_slot: str, alpha: float, total_amt: float, rank_pct: float) -> Optional[Dict]:
|
||||
"""计算 Z-Score"""
|
||||
if concept_id not in self.baselines:
|
||||
return None
|
||||
|
||||
baseline = self.baselines[concept_id]
|
||||
if time_slot not in baseline:
|
||||
return None
|
||||
|
||||
bl = baseline[time_slot]
|
||||
|
||||
alpha_z = np.clip((alpha - bl['alpha_mean']) / bl['alpha_std'], -5, 5)
|
||||
amt_z = np.clip((total_amt - bl['amt_mean']) / bl['amt_std'], -5, 5)
|
||||
rank_z = np.clip((rank_pct - bl['rank_mean']) / bl['rank_std'], -5, 5)
|
||||
|
||||
# 动量(基于 Z-Score 历史)
|
||||
history = list(self.zscore_history[concept_id])
|
||||
mom_3m = 0.0
|
||||
mom_5m = 0.0
|
||||
|
||||
if len(history) >= 3:
|
||||
recent = [h['alpha_zscore'] for h in history[-3:]]
|
||||
older = [h['alpha_zscore'] for h in history[-6:-3]] if len(history) >= 6 else [history[0]['alpha_zscore']]
|
||||
mom_3m = np.mean(recent) - np.mean(older)
|
||||
|
||||
if len(history) >= 5:
|
||||
recent = [h['alpha_zscore'] for h in history[-5:]]
|
||||
older = [h['alpha_zscore'] for h in history[-10:-5]] if len(history) >= 10 else [history[0]['alpha_zscore']]
|
||||
mom_5m = np.mean(recent) - np.mean(older)
|
||||
|
||||
return {
|
||||
'alpha_zscore': float(alpha_z),
|
||||
'amt_zscore': float(amt_z),
|
||||
'rank_zscore': float(rank_z),
|
||||
'momentum_3m': float(mom_3m),
|
||||
'momentum_5m': float(mom_5m),
|
||||
}
|
||||
|
||||
@torch.no_grad()
|
||||
def _ml_score(self, sequences: np.ndarray) -> np.ndarray:
|
||||
"""批量 ML 评分"""
|
||||
if self.model is None or len(sequences) == 0:
|
||||
return np.zeros(len(sequences))
|
||||
|
||||
x = torch.FloatTensor(sequences).to(self.device)
|
||||
errors = self.model.compute_reconstruction_error(x, reduction='none')
|
||||
last_errors = errors[:, -1].cpu().numpy()
|
||||
|
||||
# 转换为 0-100 分数
|
||||
if self.thresholds:
|
||||
p50 = self.thresholds.get('median', 0.001)
|
||||
p99 = self.thresholds.get('p99', 0.05)
|
||||
scores = 50 + (last_errors - p50) / (p99 - p50 + 1e-6) * 49
|
||||
else:
|
||||
scores = last_errors * 1000
|
||||
|
||||
return np.clip(scores, 0, 100)
|
||||
|
||||
def detect(self, trade_date: str = None) -> List[Dict]:
|
||||
"""检测指定日期的异动"""
|
||||
trade_date = trade_date or datetime.now().strftime('%Y-%m-%d')
|
||||
print(f"\n检测 {trade_date} 的异动...")
|
||||
|
||||
# 重置状态
|
||||
self.zscore_history.clear()
|
||||
self.anomaly_candidates.clear()
|
||||
self.cooldown.clear()
|
||||
|
||||
# 获取数据
|
||||
raw_df = self._get_realtime_data(trade_date)
|
||||
if raw_df.empty:
|
||||
print("无数据")
|
||||
return []
|
||||
|
||||
timestamps = sorted(raw_df['timestamp'].unique())
|
||||
print(f"时间点数: {len(timestamps)}")
|
||||
|
||||
all_alerts = []
|
||||
|
||||
for ts in timestamps:
|
||||
ts_data = raw_df[raw_df['timestamp'] == ts]
|
||||
time_slot = time_to_slot(ts)
|
||||
|
||||
candidates = []
|
||||
|
||||
# 计算每个概念的 Z-Score
|
||||
for _, row in ts_data.iterrows():
|
||||
cid = row['concept_id']
|
||||
|
||||
zscore = self._compute_zscore(
|
||||
cid, time_slot,
|
||||
row['alpha'], row['total_amt'], row['rank_pct']
|
||||
)
|
||||
|
||||
if zscore is None:
|
||||
continue
|
||||
|
||||
# 完整特征
|
||||
features = {
|
||||
**zscore,
|
||||
'alpha': row['alpha'],
|
||||
'limit_up_ratio': row['limit_up_ratio'],
|
||||
'total_amt': row['total_amt'],
|
||||
}
|
||||
|
||||
# 更新历史
|
||||
self.zscore_history[cid].append(zscore)
|
||||
|
||||
# 规则评分
|
||||
rule_score, triggered = score_rules_zscore(features)
|
||||
|
||||
candidates.append((cid, features, rule_score, triggered))
|
||||
|
||||
if not candidates:
|
||||
continue
|
||||
|
||||
# 批量 ML 评分
|
||||
sequences = []
|
||||
valid_candidates = []
|
||||
|
||||
for cid, features, rule_score, triggered in candidates:
|
||||
history = list(self.zscore_history[cid])
|
||||
if len(history) >= CONFIG['seq_len']:
|
||||
seq = np.array([[h['alpha_zscore'], h['amt_zscore'], h['rank_zscore'],
|
||||
h['momentum_3m'], h['momentum_5m'], features['limit_up_ratio']]
|
||||
for h in history])
|
||||
sequences.append(seq)
|
||||
valid_candidates.append((cid, features, rule_score, triggered))
|
||||
|
||||
if not sequences:
|
||||
continue
|
||||
|
||||
ml_scores = self._ml_score(np.array(sequences))
|
||||
|
||||
# 融合 + 确认
|
||||
for i, (cid, features, rule_score, triggered) in enumerate(valid_candidates):
|
||||
ml_score = ml_scores[i]
|
||||
final_score = CONFIG['rule_weight'] * rule_score + CONFIG['ml_weight'] * ml_score
|
||||
|
||||
# 判断触发
|
||||
is_triggered = (
|
||||
rule_score >= CONFIG['rule_trigger'] or
|
||||
ml_score >= CONFIG['ml_trigger'] or
|
||||
final_score >= CONFIG['fusion_trigger']
|
||||
)
|
||||
|
||||
self.anomaly_candidates[cid].append((ts, final_score))
|
||||
|
||||
if not is_triggered:
|
||||
continue
|
||||
|
||||
# 冷却期
|
||||
if cid in self.cooldown:
|
||||
if (ts - self.cooldown[cid]).total_seconds() < CONFIG['cooldown_minutes'] * 60:
|
||||
continue
|
||||
|
||||
# 持续确认
|
||||
recent = list(self.anomaly_candidates[cid])
|
||||
if len(recent) < CONFIG['confirm_window']:
|
||||
continue
|
||||
|
||||
exceed = sum(1 for _, s in recent if s >= CONFIG['fusion_trigger'])
|
||||
ratio = exceed / len(recent)
|
||||
|
||||
if ratio < CONFIG['confirm_ratio']:
|
||||
continue
|
||||
|
||||
# 确认异动!
|
||||
self.cooldown[cid] = ts
|
||||
|
||||
alpha = features['alpha']
|
||||
alert_type = 'surge_up' if alpha >= 1.5 else 'surge_down' if alpha <= -1.5 else 'surge'
|
||||
|
||||
concept_name = next((c['concept_name'] for c in self.concepts if c['concept_id'] == cid), cid)
|
||||
|
||||
all_alerts.append({
|
||||
'concept_id': cid,
|
||||
'concept_name': concept_name,
|
||||
'alert_time': ts,
|
||||
'trade_date': trade_date,
|
||||
'alert_type': alert_type,
|
||||
'final_score': float(final_score),
|
||||
'rule_score': float(rule_score),
|
||||
'ml_score': float(ml_score),
|
||||
'confirm_ratio': float(ratio),
|
||||
'alpha': float(alpha),
|
||||
'alpha_zscore': float(features['alpha_zscore']),
|
||||
'amt_zscore': float(features['amt_zscore']),
|
||||
'rank_zscore': float(features['rank_zscore']),
|
||||
'momentum_3m': float(features['momentum_3m']),
|
||||
'momentum_5m': float(features['momentum_5m']),
|
||||
'limit_up_ratio': float(features['limit_up_ratio']),
|
||||
'triggered_rules': triggered,
|
||||
'trigger_reason': f"融合({final_score:.0f})+确认({ratio:.0%})",
|
||||
})
|
||||
|
||||
print(f"检测到 {len(all_alerts)} 个异动")
|
||||
return all_alerts
|
||||
|
||||
|
||||
# ==================== 数据库存储 ====================
|
||||
|
||||
def create_v2_table():
|
||||
"""创建 V2 异动表(如果不存在)"""
|
||||
engine = get_mysql_engine()
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS concept_anomaly_v2 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
concept_id VARCHAR(50) NOT NULL,
|
||||
alert_time DATETIME NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
alert_type VARCHAR(20) NOT NULL,
|
||||
final_score FLOAT,
|
||||
rule_score FLOAT,
|
||||
ml_score FLOAT,
|
||||
trigger_reason VARCHAR(200),
|
||||
confirm_ratio FLOAT,
|
||||
alpha FLOAT,
|
||||
alpha_zscore FLOAT,
|
||||
amt_zscore FLOAT,
|
||||
rank_zscore FLOAT,
|
||||
momentum_3m FLOAT,
|
||||
momentum_5m FLOAT,
|
||||
limit_up_ratio FLOAT,
|
||||
triggered_rules TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_concept_time (concept_id, alert_time),
|
||||
INDEX idx_trade_date (trade_date),
|
||||
INDEX idx_alert_type (alert_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""))
|
||||
print("concept_anomaly_v2 表已就绪")
|
||||
|
||||
|
||||
def save_alerts_to_db(alerts: List[Dict]) -> int:
|
||||
"""保存异动到数据库"""
|
||||
if not alerts:
|
||||
return 0
|
||||
|
||||
engine = get_mysql_engine()
|
||||
saved = 0
|
||||
|
||||
with engine.begin() as conn:
|
||||
for alert in alerts:
|
||||
try:
|
||||
insert_sql = text("""
|
||||
INSERT IGNORE INTO concept_anomaly_v2
|
||||
(concept_id, alert_time, trade_date, alert_type,
|
||||
final_score, rule_score, ml_score, trigger_reason, confirm_ratio,
|
||||
alpha, alpha_zscore, amt_zscore, rank_zscore,
|
||||
momentum_3m, momentum_5m, limit_up_ratio, triggered_rules)
|
||||
VALUES
|
||||
(:concept_id, :alert_time, :trade_date, :alert_type,
|
||||
:final_score, :rule_score, :ml_score, :trigger_reason, :confirm_ratio,
|
||||
:alpha, :alpha_zscore, :amt_zscore, :rank_zscore,
|
||||
:momentum_3m, :momentum_5m, :limit_up_ratio, :triggered_rules)
|
||||
""")
|
||||
|
||||
result = conn.execute(insert_sql, {
|
||||
'concept_id': alert['concept_id'],
|
||||
'alert_time': alert['alert_time'],
|
||||
'trade_date': alert['trade_date'],
|
||||
'alert_type': alert['alert_type'],
|
||||
'final_score': alert['final_score'],
|
||||
'rule_score': alert['rule_score'],
|
||||
'ml_score': alert['ml_score'],
|
||||
'trigger_reason': alert['trigger_reason'],
|
||||
'confirm_ratio': alert['confirm_ratio'],
|
||||
'alpha': alert['alpha'],
|
||||
'alpha_zscore': alert['alpha_zscore'],
|
||||
'amt_zscore': alert['amt_zscore'],
|
||||
'rank_zscore': alert['rank_zscore'],
|
||||
'momentum_3m': alert['momentum_3m'],
|
||||
'momentum_5m': alert['momentum_5m'],
|
||||
'limit_up_ratio': alert['limit_up_ratio'],
|
||||
'triggered_rules': json.dumps(alert.get('triggered_rules', []), ensure_ascii=False),
|
||||
})
|
||||
|
||||
if result.rowcount > 0:
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
print(f"保存失败: {alert['concept_id']} - {e}")
|
||||
|
||||
return saved
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--date', type=str, default=None)
|
||||
parser.add_argument('--no-save', action='store_true', help='不保存到数据库,只打印')
|
||||
args = parser.parse_args()
|
||||
|
||||
# 确保表存在
|
||||
if not args.no_save:
|
||||
create_v2_table()
|
||||
|
||||
detector = RealtimeDetectorV2()
|
||||
alerts = detector.detect(args.date)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"检测结果 ({len(alerts)} 个异动)")
|
||||
print('='*60)
|
||||
|
||||
for a in alerts[:20]:
|
||||
print(f"[{a['alert_time'].strftime('%H:%M') if hasattr(a['alert_time'], 'strftime') else a['alert_time']}] "
|
||||
f"{a['concept_name']} | {a['alert_type']} | "
|
||||
f"分数={a['final_score']:.0f} 确认={a['confirm_ratio']:.0%} "
|
||||
f"α={a['alpha']:.2f}% αZ={a['alpha_zscore']:.1f}")
|
||||
|
||||
if len(alerts) > 20:
|
||||
print(f"... 共 {len(alerts)} 个")
|
||||
|
||||
# 保存到数据库
|
||||
if not args.no_save and alerts:
|
||||
saved = save_alerts_to_db(alerts)
|
||||
print(f"\n✅ 已保存 {saved}/{len(alerts)} 条到 concept_anomaly_v2 表")
|
||||
elif args.no_save:
|
||||
print(f"\n⚠️ --no-save 模式,未保存到数据库")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,25 +0,0 @@
|
||||
# 概念异动检测 ML 模块依赖
|
||||
# 安装: pip install -r ml/requirements.txt
|
||||
|
||||
# PyTorch (根据 CUDA 版本选择)
|
||||
# 5090 显卡需要 CUDA 12.x
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/cu124
|
||||
torch>=2.0.0
|
||||
|
||||
# 数据处理
|
||||
numpy>=1.24.0
|
||||
pandas>=2.0.0
|
||||
pyarrow>=14.0.0
|
||||
|
||||
# 数据库
|
||||
clickhouse-driver>=0.2.6
|
||||
elasticsearch>=7.0.0,<8.0.0
|
||||
sqlalchemy>=2.0.0
|
||||
pymysql>=1.1.0
|
||||
|
||||
# 训练工具
|
||||
tqdm>=4.65.0
|
||||
|
||||
# 可选: 可视化
|
||||
# matplotlib>=3.7.0
|
||||
# tensorboard>=2.14.0
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 概念异动检测模型训练脚本 (Linux)
|
||||
#
|
||||
# 使用方法:
|
||||
# chmod +x run_training.sh
|
||||
# ./run_training.sh
|
||||
#
|
||||
# 或指定参数:
|
||||
# ./run_training.sh --start 2022-01-01 --epochs 100
|
||||
|
||||
set -e
|
||||
|
||||
echo "============================================================"
|
||||
echo "概念异动检测模型训练流程"
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR/.."
|
||||
|
||||
echo "[1/4] 检查环境..."
|
||||
python3 --version || { echo "Python3 未找到!"; exit 1; }
|
||||
|
||||
# 检查 GPU
|
||||
if python3 -c "import torch; print(f'CUDA: {torch.cuda.is_available()}')" 2>/dev/null; then
|
||||
echo "PyTorch GPU 检测完成"
|
||||
else
|
||||
echo "警告: PyTorch 未安装或无法检测 GPU"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[2/4] 检查依赖..."
|
||||
pip3 install -q torch pandas numpy pyarrow tqdm clickhouse-driver elasticsearch sqlalchemy pymysql
|
||||
|
||||
echo ""
|
||||
echo "[3/4] 准备训练数据..."
|
||||
echo "从 ClickHouse 提取历史数据,这可能需要较长时间..."
|
||||
echo ""
|
||||
|
||||
# 解析参数
|
||||
START_DATE="2022-01-01"
|
||||
END_DATE=""
|
||||
EPOCHS=100
|
||||
BATCH_SIZE=256
|
||||
TRAIN_END="2025-06-30"
|
||||
VAL_END="2025-09-30"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--start)
|
||||
START_DATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--end)
|
||||
END_DATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--epochs)
|
||||
EPOCHS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--batch_size)
|
||||
BATCH_SIZE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--train_end)
|
||||
TRAIN_END="$2"
|
||||
shift 2
|
||||
;;
|
||||
--val_end)
|
||||
VAL_END="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 数据准备
|
||||
if [ -n "$END_DATE" ]; then
|
||||
python3 ml/prepare_data.py --start "$START_DATE" --end "$END_DATE"
|
||||
else
|
||||
python3 ml/prepare_data.py --start "$START_DATE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[4/4] 训练模型..."
|
||||
echo "使用 GPU 加速训练..."
|
||||
echo ""
|
||||
|
||||
python3 ml/train.py --epochs "$EPOCHS" --batch_size "$BATCH_SIZE" --train_end "$TRAIN_END" --val_end "$VAL_END"
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo "训练完成!"
|
||||
echo "模型保存在: ml/checkpoints/"
|
||||
echo "============================================================"
|
||||
808
ml/train.py
@@ -1,808 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Transformer Autoencoder 训练脚本 (修复版)
|
||||
|
||||
修复问题:
|
||||
1. 按概念分组构建序列,避免跨概念切片
|
||||
2. 按时间(日期)切分数据集,避免数据泄露
|
||||
3. 使用 RobustScaler + Clipping 处理非平稳性
|
||||
4. 使用验证集计算阈值
|
||||
|
||||
训练流程:
|
||||
1. 加载预处理好的特征数据(parquet 文件)
|
||||
2. 按概念分组,在每个概念内部构建序列
|
||||
3. 按日期划分训练/验证/测试集
|
||||
4. 训练 Autoencoder(最小化重构误差)
|
||||
5. 保存模型和阈值
|
||||
|
||||
使用方法:
|
||||
python train.py --data_dir ml/data --epochs 100 --batch_size 256
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import Dataset, DataLoader
|
||||
from torch.optim import AdamW
|
||||
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
|
||||
from tqdm import tqdm
|
||||
|
||||
from model import TransformerAutoencoder, AnomalyDetectionLoss, count_parameters
|
||||
|
||||
# 性能优化:启用 cuDNN benchmark(对固定输入尺寸自动选择最快算法)
|
||||
torch.backends.cudnn.benchmark = True
|
||||
# 启用 TF32(RTX 30/40 系列特有,提速约 3 倍)
|
||||
torch.backends.cuda.matmul.allow_tf32 = True
|
||||
torch.backends.cudnn.allow_tf32 = True
|
||||
|
||||
# 可视化(可选)
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg') # 无头模式,不需要显示器
|
||||
import matplotlib.pyplot as plt
|
||||
HAS_MATPLOTLIB = True
|
||||
except ImportError:
|
||||
HAS_MATPLOTLIB = False
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
TRAIN_CONFIG = {
|
||||
# 数据配置
|
||||
'seq_len': 30, # 输入序列长度(30分钟)
|
||||
'stride': 5, # 滑动窗口步长
|
||||
|
||||
# 时间切分(按日期)
|
||||
'train_end_date': '2024-06-30', # 训练集截止日期
|
||||
'val_end_date': '2024-09-30', # 验证集截止日期(之后为测试集)
|
||||
|
||||
# 特征配置
|
||||
'features': [
|
||||
'alpha', # 超额收益
|
||||
'alpha_delta', # Alpha 变化率
|
||||
'amt_ratio', # 成交额比率
|
||||
'amt_delta', # 成交额变化率
|
||||
'rank_pct', # Alpha 排名百分位
|
||||
'limit_up_ratio', # 涨停比例
|
||||
],
|
||||
|
||||
# 训练配置(针对 4x RTX 4090 优化)
|
||||
'batch_size': 4096, # 256 -> 4096(大幅增加,充分利用显存)
|
||||
'epochs': 100,
|
||||
'learning_rate': 3e-4, # 1e-4 -> 3e-4(大 batch 需要更大学习率)
|
||||
'weight_decay': 1e-5,
|
||||
'gradient_clip': 1.0,
|
||||
|
||||
# 早停配置
|
||||
'patience': 10,
|
||||
'min_delta': 1e-6,
|
||||
|
||||
# 模型配置(LSTM Autoencoder,简洁有效)
|
||||
'model': {
|
||||
'n_features': 6,
|
||||
'hidden_dim': 32, # LSTM 隐藏维度(小)
|
||||
'latent_dim': 4, # 瓶颈维度(非常小!关键)
|
||||
'num_layers': 1, # LSTM 层数
|
||||
'dropout': 0.2,
|
||||
'bidirectional': True, # 双向编码器
|
||||
},
|
||||
|
||||
# 标准化配置
|
||||
'use_instance_norm': True, # 模型内部使用 Instance Norm(推荐)
|
||||
'clip_value': 10.0, # 简单截断极端值
|
||||
|
||||
# 阈值配置
|
||||
'threshold_percentiles': [90, 95, 99],
|
||||
}
|
||||
|
||||
|
||||
# ==================== 数据加载(修复版)====================
|
||||
|
||||
def load_data_by_date(data_dir: str, features: List[str]) -> Dict[str, pd.DataFrame]:
|
||||
"""
|
||||
按日期加载数据,返回 {date: DataFrame} 字典
|
||||
|
||||
每个 DataFrame 包含该日所有概念的所有时间点数据
|
||||
"""
|
||||
data_path = Path(data_dir)
|
||||
parquet_files = sorted(data_path.glob("features_*.parquet"))
|
||||
|
||||
if not parquet_files:
|
||||
raise FileNotFoundError(f"未找到 parquet 文件: {data_dir}")
|
||||
|
||||
print(f"找到 {len(parquet_files)} 个数据文件")
|
||||
|
||||
date_data = {}
|
||||
|
||||
for pf in tqdm(parquet_files, desc="加载数据"):
|
||||
# 提取日期
|
||||
date = pf.stem.replace('features_', '')
|
||||
|
||||
df = pd.read_parquet(pf)
|
||||
|
||||
# 检查必要列
|
||||
required_cols = features + ['concept_id', 'timestamp']
|
||||
missing_cols = [c for c in required_cols if c not in df.columns]
|
||||
if missing_cols:
|
||||
print(f"警告: {date} 缺少列: {missing_cols}, 跳过")
|
||||
continue
|
||||
|
||||
date_data[date] = df
|
||||
|
||||
print(f"成功加载 {len(date_data)} 天的数据")
|
||||
return date_data
|
||||
|
||||
|
||||
def split_data_by_date(
|
||||
date_data: Dict[str, pd.DataFrame],
|
||||
train_end: str,
|
||||
val_end: str
|
||||
) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
||||
"""
|
||||
按日期严格划分数据集
|
||||
|
||||
- 训练集: <= train_end
|
||||
- 验证集: train_end < date <= val_end
|
||||
- 测试集: > val_end
|
||||
"""
|
||||
train_data = {}
|
||||
val_data = {}
|
||||
test_data = {}
|
||||
|
||||
for date, df in date_data.items():
|
||||
if date <= train_end:
|
||||
train_data[date] = df
|
||||
elif date <= val_end:
|
||||
val_data[date] = df
|
||||
else:
|
||||
test_data[date] = df
|
||||
|
||||
print(f"数据集划分(按日期):")
|
||||
print(f" 训练集: {len(train_data)} 天 (<= {train_end})")
|
||||
print(f" 验证集: {len(val_data)} 天 ({train_end} ~ {val_end})")
|
||||
print(f" 测试集: {len(test_data)} 天 (> {val_end})")
|
||||
|
||||
return train_data, val_data, test_data
|
||||
|
||||
|
||||
def build_sequences_by_concept(
|
||||
date_data: Dict[str, pd.DataFrame],
|
||||
features: List[str],
|
||||
seq_len: int,
|
||||
stride: int
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
按概念分组构建序列(性能优化版)
|
||||
|
||||
使用 groupby 一次性分组,避免重复扫描大数组
|
||||
|
||||
1. 将所有日期的数据合并
|
||||
2. 使用 groupby 按 concept_id 分组
|
||||
3. 在每个概念内部,按时间排序并滑动窗口
|
||||
4. 合并所有序列
|
||||
"""
|
||||
# 合并所有日期的数据
|
||||
all_dfs = []
|
||||
for date, df in sorted(date_data.items()):
|
||||
df = df.copy()
|
||||
df['date'] = date
|
||||
all_dfs.append(df)
|
||||
|
||||
if not all_dfs:
|
||||
return np.array([])
|
||||
|
||||
combined = pd.concat(all_dfs, ignore_index=True)
|
||||
|
||||
# 预先排序(按概念、日期、时间),这样 groupby 会更快
|
||||
combined = combined.sort_values(['concept_id', 'date', 'timestamp'])
|
||||
|
||||
# 使用 groupby 一次性分组(性能关键!)
|
||||
all_sequences = []
|
||||
grouped = combined.groupby('concept_id', sort=False)
|
||||
n_concepts = len(grouped)
|
||||
|
||||
for concept_id, concept_df in tqdm(grouped, desc="构建序列", total=n_concepts, leave=False):
|
||||
# 已经排序过了,直接提取特征
|
||||
feature_data = concept_df[features].values
|
||||
|
||||
# 处理缺失值
|
||||
feature_data = np.nan_to_num(feature_data, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
# 在该概念内部滑动窗口
|
||||
n_points = len(feature_data)
|
||||
for start in range(0, n_points - seq_len + 1, stride):
|
||||
seq = feature_data[start:start + seq_len]
|
||||
all_sequences.append(seq)
|
||||
|
||||
if not all_sequences:
|
||||
return np.array([])
|
||||
|
||||
sequences = np.array(all_sequences)
|
||||
print(f" 构建序列: {len(sequences):,} 条 (来自 {n_concepts} 个概念)")
|
||||
|
||||
return sequences
|
||||
|
||||
|
||||
# ==================== 数据集 ====================
|
||||
|
||||
class SequenceDataset(Dataset):
|
||||
"""序列数据集(已经构建好的序列)"""
|
||||
|
||||
def __init__(self, sequences: np.ndarray):
|
||||
self.sequences = torch.FloatTensor(sequences)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.sequences)
|
||||
|
||||
def __getitem__(self, idx: int) -> torch.Tensor:
|
||||
return self.sequences[idx]
|
||||
|
||||
|
||||
# ==================== 训练器 ====================
|
||||
|
||||
class EarlyStopping:
|
||||
"""早停机制"""
|
||||
|
||||
def __init__(self, patience: int = 10, min_delta: float = 1e-6):
|
||||
self.patience = patience
|
||||
self.min_delta = min_delta
|
||||
self.counter = 0
|
||||
self.best_loss = float('inf')
|
||||
self.early_stop = False
|
||||
|
||||
def __call__(self, val_loss: float) -> bool:
|
||||
if val_loss < self.best_loss - self.min_delta:
|
||||
self.best_loss = val_loss
|
||||
self.counter = 0
|
||||
else:
|
||||
self.counter += 1
|
||||
if self.counter >= self.patience:
|
||||
self.early_stop = True
|
||||
|
||||
return self.early_stop
|
||||
|
||||
|
||||
class Trainer:
|
||||
"""模型训练器(支持 AMP 混合精度加速)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: nn.Module,
|
||||
train_loader: DataLoader,
|
||||
val_loader: DataLoader,
|
||||
config: Dict,
|
||||
device: torch.device,
|
||||
save_dir: str = 'ml/checkpoints'
|
||||
):
|
||||
self.model = model.to(device)
|
||||
self.train_loader = train_loader
|
||||
self.val_loader = val_loader
|
||||
self.config = config
|
||||
self.device = device
|
||||
self.save_dir = Path(save_dir)
|
||||
self.save_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 优化器
|
||||
self.optimizer = AdamW(
|
||||
model.parameters(),
|
||||
lr=config['learning_rate'],
|
||||
weight_decay=config['weight_decay']
|
||||
)
|
||||
|
||||
# 学习率调度器
|
||||
self.scheduler = CosineAnnealingWarmRestarts(
|
||||
self.optimizer,
|
||||
T_0=10,
|
||||
T_mult=2,
|
||||
eta_min=1e-6
|
||||
)
|
||||
|
||||
# 损失函数(简化版,只用 MSE)
|
||||
self.criterion = AnomalyDetectionLoss()
|
||||
|
||||
# 早停
|
||||
self.early_stopping = EarlyStopping(
|
||||
patience=config['patience'],
|
||||
min_delta=config['min_delta']
|
||||
)
|
||||
|
||||
# AMP 混合精度训练(大幅提速 + 省显存)
|
||||
self.use_amp = torch.cuda.is_available()
|
||||
self.scaler = torch.cuda.amp.GradScaler() if self.use_amp else None
|
||||
if self.use_amp:
|
||||
print(" ✓ 启用 AMP 混合精度训练")
|
||||
|
||||
# 训练历史
|
||||
self.history = {
|
||||
'train_loss': [],
|
||||
'val_loss': [],
|
||||
'learning_rate': [],
|
||||
}
|
||||
|
||||
self.best_val_loss = float('inf')
|
||||
|
||||
def train_epoch(self) -> float:
|
||||
"""训练一个 epoch(使用 AMP 混合精度)"""
|
||||
self.model.train()
|
||||
total_loss = 0.0
|
||||
n_batches = 0
|
||||
|
||||
pbar = tqdm(self.train_loader, desc="Training", leave=False)
|
||||
for batch in pbar:
|
||||
batch = batch.to(self.device, non_blocking=True) # 异步传输
|
||||
|
||||
self.optimizer.zero_grad(set_to_none=True) # 更快的梯度清零
|
||||
|
||||
# AMP 混合精度前向传播
|
||||
if self.use_amp:
|
||||
with torch.cuda.amp.autocast():
|
||||
output, latent = self.model(batch)
|
||||
loss, loss_dict = self.criterion(output, batch, latent)
|
||||
|
||||
# AMP 反向传播
|
||||
self.scaler.scale(loss).backward()
|
||||
|
||||
# 梯度裁剪(需要 unscale)
|
||||
self.scaler.unscale_(self.optimizer)
|
||||
torch.nn.utils.clip_grad_norm_(
|
||||
self.model.parameters(),
|
||||
self.config['gradient_clip']
|
||||
)
|
||||
|
||||
self.scaler.step(self.optimizer)
|
||||
self.scaler.update()
|
||||
else:
|
||||
# 非 AMP 模式
|
||||
output, latent = self.model(batch)
|
||||
loss, loss_dict = self.criterion(output, batch, latent)
|
||||
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(
|
||||
self.model.parameters(),
|
||||
self.config['gradient_clip']
|
||||
)
|
||||
self.optimizer.step()
|
||||
|
||||
total_loss += loss.item()
|
||||
n_batches += 1
|
||||
|
||||
pbar.set_postfix({'loss': f"{loss.item():.4f}"})
|
||||
|
||||
return total_loss / n_batches
|
||||
|
||||
@torch.no_grad()
|
||||
def validate(self) -> float:
|
||||
"""验证(使用 AMP)"""
|
||||
self.model.eval()
|
||||
total_loss = 0.0
|
||||
n_batches = 0
|
||||
|
||||
for batch in self.val_loader:
|
||||
batch = batch.to(self.device, non_blocking=True)
|
||||
|
||||
if self.use_amp:
|
||||
with torch.cuda.amp.autocast():
|
||||
output, latent = self.model(batch)
|
||||
loss, _ = self.criterion(output, batch, latent)
|
||||
else:
|
||||
output, latent = self.model(batch)
|
||||
loss, _ = self.criterion(output, batch, latent)
|
||||
|
||||
total_loss += loss.item()
|
||||
n_batches += 1
|
||||
|
||||
return total_loss / n_batches
|
||||
|
||||
def save_checkpoint(self, epoch: int, val_loss: float, is_best: bool = False):
|
||||
"""保存检查点"""
|
||||
# 处理 DataParallel 包装
|
||||
model_to_save = self.model.module if hasattr(self.model, 'module') else self.model
|
||||
|
||||
checkpoint = {
|
||||
'epoch': epoch,
|
||||
'model_state_dict': model_to_save.state_dict(),
|
||||
'optimizer_state_dict': self.optimizer.state_dict(),
|
||||
'scheduler_state_dict': self.scheduler.state_dict(),
|
||||
'val_loss': val_loss,
|
||||
'config': self.config,
|
||||
}
|
||||
|
||||
# 保存最新检查点
|
||||
torch.save(checkpoint, self.save_dir / 'last_checkpoint.pt')
|
||||
|
||||
# 保存最佳模型
|
||||
if is_best:
|
||||
torch.save(checkpoint, self.save_dir / 'best_model.pt')
|
||||
print(f" ✓ 保存最佳模型 (val_loss: {val_loss:.6f})")
|
||||
|
||||
def train(self, epochs: int):
|
||||
"""完整训练流程"""
|
||||
print(f"\n开始训练 ({epochs} epochs)...")
|
||||
print(f"设备: {self.device}")
|
||||
print(f"模型参数量: {count_parameters(self.model):,}")
|
||||
|
||||
for epoch in range(1, epochs + 1):
|
||||
print(f"\nEpoch {epoch}/{epochs}")
|
||||
|
||||
# 训练
|
||||
train_loss = self.train_epoch()
|
||||
|
||||
# 验证
|
||||
val_loss = self.validate()
|
||||
|
||||
# 更新学习率
|
||||
self.scheduler.step()
|
||||
current_lr = self.optimizer.param_groups[0]['lr']
|
||||
|
||||
# 记录历史
|
||||
self.history['train_loss'].append(train_loss)
|
||||
self.history['val_loss'].append(val_loss)
|
||||
self.history['learning_rate'].append(current_lr)
|
||||
|
||||
# 打印进度
|
||||
print(f" Train Loss: {train_loss:.6f}")
|
||||
print(f" Val Loss: {val_loss:.6f}")
|
||||
print(f" LR: {current_lr:.2e}")
|
||||
|
||||
# 保存检查点
|
||||
is_best = val_loss < self.best_val_loss
|
||||
if is_best:
|
||||
self.best_val_loss = val_loss
|
||||
self.save_checkpoint(epoch, val_loss, is_best)
|
||||
|
||||
# 早停检查
|
||||
if self.early_stopping(val_loss):
|
||||
print(f"\n早停触发!验证损失已 {self.early_stopping.patience} 个 epoch 未改善")
|
||||
break
|
||||
|
||||
print(f"\n训练完成!最佳验证损失: {self.best_val_loss:.6f}")
|
||||
|
||||
# 保存训练历史
|
||||
self.save_history()
|
||||
|
||||
return self.history
|
||||
|
||||
def save_history(self):
|
||||
"""保存训练历史"""
|
||||
history_path = self.save_dir / 'training_history.json'
|
||||
with open(history_path, 'w') as f:
|
||||
json.dump(self.history, f, indent=2)
|
||||
print(f"训练历史已保存: {history_path}")
|
||||
|
||||
# 绘制训练曲线
|
||||
self.plot_training_curves()
|
||||
|
||||
def plot_training_curves(self):
|
||||
"""绘制训练曲线"""
|
||||
if not HAS_MATPLOTLIB:
|
||||
print("matplotlib 未安装,跳过绘图")
|
||||
return
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
||||
|
||||
epochs = range(1, len(self.history['train_loss']) + 1)
|
||||
|
||||
# 1. Loss 曲线
|
||||
ax1 = axes[0]
|
||||
ax1.plot(epochs, self.history['train_loss'], 'b-', label='Train Loss', linewidth=2)
|
||||
ax1.plot(epochs, self.history['val_loss'], 'r-', label='Val Loss', linewidth=2)
|
||||
ax1.set_xlabel('Epoch', fontsize=12)
|
||||
ax1.set_ylabel('Loss', fontsize=12)
|
||||
ax1.set_title('Training & Validation Loss', fontsize=14)
|
||||
ax1.legend(fontsize=11)
|
||||
ax1.grid(True, alpha=0.3)
|
||||
|
||||
# 标记最佳点
|
||||
best_epoch = np.argmin(self.history['val_loss']) + 1
|
||||
best_val_loss = min(self.history['val_loss'])
|
||||
ax1.axvline(x=best_epoch, color='g', linestyle='--', alpha=0.7, label=f'Best Epoch: {best_epoch}')
|
||||
ax1.scatter([best_epoch], [best_val_loss], color='g', s=100, zorder=5)
|
||||
ax1.annotate(f'Best: {best_val_loss:.6f}', xy=(best_epoch, best_val_loss),
|
||||
xytext=(best_epoch + 2, best_val_loss + 0.0005),
|
||||
fontsize=10, color='green')
|
||||
|
||||
# 2. 学习率曲线
|
||||
ax2 = axes[1]
|
||||
ax2.plot(epochs, self.history['learning_rate'], 'g-', linewidth=2)
|
||||
ax2.set_xlabel('Epoch', fontsize=12)
|
||||
ax2.set_ylabel('Learning Rate', fontsize=12)
|
||||
ax2.set_title('Learning Rate Schedule', fontsize=14)
|
||||
ax2.set_yscale('log')
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
# 保存图片
|
||||
plot_path = self.save_dir / 'training_curves.png'
|
||||
plt.savefig(plot_path, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
print(f"训练曲线已保存: {plot_path}")
|
||||
|
||||
|
||||
# ==================== 阈值计算(使用验证集)====================
|
||||
|
||||
@torch.no_grad()
|
||||
def compute_thresholds(
|
||||
model: nn.Module,
|
||||
data_loader: DataLoader,
|
||||
device: torch.device,
|
||||
percentiles: List[float] = [90, 95, 99]
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
在验证集上计算重构误差的百分位数阈值
|
||||
|
||||
注:使用验证集而非测试集,避免数据泄露
|
||||
"""
|
||||
model.eval()
|
||||
all_errors = []
|
||||
|
||||
print("计算异动阈值(使用验证集)...")
|
||||
for batch in tqdm(data_loader, desc="Computing thresholds"):
|
||||
batch = batch.to(device)
|
||||
errors = model.compute_reconstruction_error(batch, reduction='none')
|
||||
|
||||
# 取每个序列的最后一个时刻误差(预测当前时刻)
|
||||
seq_errors = errors[:, -1] # (batch,)
|
||||
all_errors.append(seq_errors.cpu().numpy())
|
||||
|
||||
all_errors = np.concatenate(all_errors)
|
||||
|
||||
thresholds = {}
|
||||
for p in percentiles:
|
||||
threshold = np.percentile(all_errors, p)
|
||||
thresholds[f'p{p}'] = float(threshold)
|
||||
print(f" P{p}: {threshold:.6f}")
|
||||
|
||||
# 额外统计
|
||||
thresholds['mean'] = float(np.mean(all_errors))
|
||||
thresholds['std'] = float(np.std(all_errors))
|
||||
thresholds['median'] = float(np.median(all_errors))
|
||||
|
||||
print(f" Mean: {thresholds['mean']:.6f}")
|
||||
print(f" Median: {thresholds['median']:.6f}")
|
||||
print(f" Std: {thresholds['std']:.6f}")
|
||||
|
||||
return thresholds
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='训练概念异动检测模型')
|
||||
parser.add_argument('--data_dir', type=str, default='ml/data',
|
||||
help='数据目录路径')
|
||||
parser.add_argument('--epochs', type=int, default=100,
|
||||
help='训练轮数')
|
||||
parser.add_argument('--batch_size', type=int, default=4096,
|
||||
help='批次大小(4x RTX 4090 推荐 4096~8192)')
|
||||
parser.add_argument('--lr', type=float, default=3e-4,
|
||||
help='学习率(大 batch 推荐 3e-4)')
|
||||
parser.add_argument('--device', type=str, default='auto',
|
||||
help='设备 (auto/cuda/cpu)')
|
||||
parser.add_argument('--save_dir', type=str, default='ml/checkpoints',
|
||||
help='模型保存目录')
|
||||
parser.add_argument('--train_end', type=str, default='2024-06-30',
|
||||
help='训练集截止日期')
|
||||
parser.add_argument('--val_end', type=str, default='2024-09-30',
|
||||
help='验证集截止日期')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 更新配置
|
||||
config = TRAIN_CONFIG.copy()
|
||||
config['batch_size'] = args.batch_size
|
||||
config['epochs'] = args.epochs
|
||||
config['learning_rate'] = args.lr
|
||||
config['train_end_date'] = args.train_end
|
||||
config['val_end_date'] = args.val_end
|
||||
|
||||
# 设备选择
|
||||
if args.device == 'auto':
|
||||
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
else:
|
||||
device = torch.device(args.device)
|
||||
|
||||
print("=" * 60)
|
||||
print("概念异动检测模型训练(修复版)")
|
||||
print("=" * 60)
|
||||
print(f"配置:")
|
||||
print(f" 数据目录: {args.data_dir}")
|
||||
print(f" 设备: {device}")
|
||||
print(f" 批次大小: {config['batch_size']}")
|
||||
print(f" 学习率: {config['learning_rate']}")
|
||||
print(f" 训练轮数: {config['epochs']}")
|
||||
print(f" 训练集截止: {config['train_end_date']}")
|
||||
print(f" 验证集截止: {config['val_end_date']}")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 按日期加载数据
|
||||
print("\n[1/6] 加载数据...")
|
||||
date_data = load_data_by_date(args.data_dir, config['features'])
|
||||
|
||||
# 2. 按日期划分
|
||||
print("\n[2/6] 按日期划分数据集...")
|
||||
train_data, val_data, test_data = split_data_by_date(
|
||||
date_data,
|
||||
config['train_end_date'],
|
||||
config['val_end_date']
|
||||
)
|
||||
|
||||
# 3. 按概念构建序列
|
||||
print("\n[3/6] 按概念构建序列...")
|
||||
print("训练集:")
|
||||
train_sequences = build_sequences_by_concept(
|
||||
train_data, config['features'], config['seq_len'], config['stride']
|
||||
)
|
||||
print("验证集:")
|
||||
val_sequences = build_sequences_by_concept(
|
||||
val_data, config['features'], config['seq_len'], config['stride']
|
||||
)
|
||||
print("测试集:")
|
||||
test_sequences = build_sequences_by_concept(
|
||||
test_data, config['features'], config['seq_len'], config['stride']
|
||||
)
|
||||
|
||||
if len(train_sequences) == 0:
|
||||
print("错误: 训练集为空!请检查数据和日期范围")
|
||||
return
|
||||
|
||||
# 4. 数据预处理(简单截断极端值,标准化在模型内部通过 Instance Norm 完成)
|
||||
print("\n[4/6] 数据预处理...")
|
||||
print(" 注意: 使用 Instance Norm,每个序列在模型内部单独标准化")
|
||||
print(" 这样可以处理不同概念波动率差异(银行 vs 半导体)")
|
||||
|
||||
clip_value = config['clip_value']
|
||||
print(f" 截断极端值: ±{clip_value}")
|
||||
|
||||
# 简单截断极端值(防止异常数据影响训练)
|
||||
train_sequences = np.clip(train_sequences, -clip_value, clip_value)
|
||||
if len(val_sequences) > 0:
|
||||
val_sequences = np.clip(val_sequences, -clip_value, clip_value)
|
||||
if len(test_sequences) > 0:
|
||||
test_sequences = np.clip(test_sequences, -clip_value, clip_value)
|
||||
|
||||
# 保存配置
|
||||
save_dir = Path(args.save_dir)
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preprocess_params = {
|
||||
'features': config['features'],
|
||||
'normalization': 'instance_norm', # 在模型内部完成
|
||||
'clip_value': clip_value,
|
||||
'note': '标准化在模型内部通过 InstanceNorm1d 完成,无需外部 Scaler'
|
||||
}
|
||||
|
||||
with open(save_dir / 'normalization_stats.json', 'w') as f:
|
||||
json.dump(preprocess_params, f, indent=2)
|
||||
print(f" 预处理参数已保存")
|
||||
|
||||
# 5. 创建数据集和加载器
|
||||
print("\n[5/6] 创建数据加载器...")
|
||||
train_dataset = SequenceDataset(train_sequences)
|
||||
val_dataset = SequenceDataset(val_sequences) if len(val_sequences) > 0 else None
|
||||
test_dataset = SequenceDataset(test_sequences) if len(test_sequences) > 0 else None
|
||||
|
||||
print(f" 训练序列: {len(train_dataset):,}")
|
||||
print(f" 验证序列: {len(val_dataset) if val_dataset else 0:,}")
|
||||
print(f" 测试序列: {len(test_dataset) if test_dataset else 0:,}")
|
||||
|
||||
# 多卡时增加 num_workers(Linux 上可以用更多)
|
||||
n_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 1
|
||||
num_workers = min(32, 8 * n_gpus) if sys.platform != 'win32' else 0
|
||||
print(f" DataLoader workers: {num_workers}")
|
||||
print(f" Batch size: {config['batch_size']}")
|
||||
|
||||
# 大 batch + 多 worker + prefetch 提速
|
||||
train_loader = DataLoader(
|
||||
train_dataset,
|
||||
batch_size=config['batch_size'],
|
||||
shuffle=True,
|
||||
num_workers=num_workers,
|
||||
pin_memory=True,
|
||||
prefetch_factor=4 if num_workers > 0 else None, # 预取更多 batch
|
||||
persistent_workers=True if num_workers > 0 else False, # 保持 worker 存活
|
||||
drop_last=True # 丢弃不完整的最后一批,避免 batch 大小不一致
|
||||
)
|
||||
|
||||
val_loader = DataLoader(
|
||||
val_dataset,
|
||||
batch_size=config['batch_size'] * 2, # 验证时可以用更大 batch(无梯度)
|
||||
shuffle=False,
|
||||
num_workers=num_workers,
|
||||
pin_memory=True,
|
||||
prefetch_factor=4 if num_workers > 0 else None,
|
||||
persistent_workers=True if num_workers > 0 else False,
|
||||
) if val_dataset else None
|
||||
|
||||
test_loader = DataLoader(
|
||||
test_dataset,
|
||||
batch_size=config['batch_size'] * 2,
|
||||
shuffle=False,
|
||||
num_workers=num_workers,
|
||||
pin_memory=True,
|
||||
prefetch_factor=4 if num_workers > 0 else None,
|
||||
persistent_workers=True if num_workers > 0 else False,
|
||||
) if test_dataset else None
|
||||
|
||||
# 6. 训练
|
||||
print("\n[6/6] 训练模型...")
|
||||
model_config = config['model'].copy()
|
||||
model = TransformerAutoencoder(**model_config)
|
||||
|
||||
# 多卡并行
|
||||
if torch.cuda.device_count() > 1:
|
||||
print(f" 使用 {torch.cuda.device_count()} 张 GPU 并行训练")
|
||||
model = nn.DataParallel(model)
|
||||
|
||||
if val_loader is None:
|
||||
print("警告: 验证集为空,将使用训练集的一部分作为验证")
|
||||
# 简单处理:用训练集的后 10% 作为验证
|
||||
split_idx = int(len(train_dataset) * 0.9)
|
||||
train_subset = torch.utils.data.Subset(train_dataset, range(split_idx))
|
||||
val_subset = torch.utils.data.Subset(train_dataset, range(split_idx, len(train_dataset)))
|
||||
|
||||
train_loader = DataLoader(train_subset, batch_size=config['batch_size'], shuffle=True, num_workers=num_workers, pin_memory=True)
|
||||
val_loader = DataLoader(val_subset, batch_size=config['batch_size'], shuffle=False, num_workers=num_workers, pin_memory=True)
|
||||
|
||||
trainer = Trainer(
|
||||
model=model,
|
||||
train_loader=train_loader,
|
||||
val_loader=val_loader,
|
||||
config=config,
|
||||
device=device,
|
||||
save_dir=args.save_dir
|
||||
)
|
||||
|
||||
history = trainer.train(config['epochs'])
|
||||
|
||||
# 7. 计算阈值(使用验证集)
|
||||
print("\n[额外] 计算异动阈值...")
|
||||
|
||||
# 加载最佳模型
|
||||
best_checkpoint = torch.load(
|
||||
save_dir / 'best_model.pt',
|
||||
map_location=device
|
||||
)
|
||||
model.load_state_dict(best_checkpoint['model_state_dict'])
|
||||
model.to(device)
|
||||
|
||||
# 使用验证集计算阈值(避免数据泄露)
|
||||
thresholds = compute_thresholds(
|
||||
model,
|
||||
val_loader,
|
||||
device,
|
||||
config['threshold_percentiles']
|
||||
)
|
||||
|
||||
# 保存阈值
|
||||
with open(save_dir / 'thresholds.json', 'w') as f:
|
||||
json.dump(thresholds, f, indent=2)
|
||||
print(f"阈值已保存")
|
||||
|
||||
# 保存完整配置
|
||||
with open(save_dir / 'config.json', 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("训练完成!")
|
||||
print("=" * 60)
|
||||
print(f"模型保存位置: {args.save_dir}")
|
||||
print(f" - best_model.pt: 最佳模型权重")
|
||||
print(f" - thresholds.json: 异动阈值")
|
||||
print(f" - normalization_stats.json: 标准化参数")
|
||||
print(f" - config.json: 训练配置")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
622
ml/train_v2.py
@@ -1,622 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
训练脚本 V2 - 基于 Z-Score 特征的 LSTM Autoencoder
|
||||
|
||||
改进点:
|
||||
1. 使用 Z-Score 特征(相对于同时间片历史的偏离)
|
||||
2. 短序列:10分钟(不需要30分钟预热)
|
||||
3. 开盘即可检测:9:30 直接有特征
|
||||
|
||||
模型输入:
|
||||
- 过去10分钟的 Z-Score 特征序列
|
||||
- 特征:alpha_zscore, amt_zscore, rank_zscore, momentum_3m, momentum_5m, limit_up_ratio
|
||||
|
||||
模型学习:
|
||||
- 学习 Z-Score 序列的"正常演化模式"
|
||||
- 异动 = Z-Score 序列的异常演化(重构误差大)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import Dataset, DataLoader
|
||||
from torch.optim import AdamW
|
||||
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
|
||||
from tqdm import tqdm
|
||||
|
||||
from model import TransformerAutoencoder, AnomalyDetectionLoss, count_parameters
|
||||
|
||||
# 性能优化
|
||||
torch.backends.cudnn.benchmark = True
|
||||
torch.backends.cuda.matmul.allow_tf32 = True
|
||||
torch.backends.cudnn.allow_tf32 = True
|
||||
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
HAS_MATPLOTLIB = True
|
||||
except ImportError:
|
||||
HAS_MATPLOTLIB = False
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
TRAIN_CONFIG = {
|
||||
# 数据配置(改进!)
|
||||
'seq_len': 10, # 10分钟序列(不是30分钟!)
|
||||
'stride': 2, # 步长2分钟
|
||||
|
||||
# 时间切分
|
||||
'train_end_date': '2024-06-30',
|
||||
'val_end_date': '2024-09-30',
|
||||
|
||||
# V2 特征(Z-Score 为主)
|
||||
'features': [
|
||||
'alpha_zscore', # Alpha 的 Z-Score
|
||||
'amt_zscore', # 成交额的 Z-Score
|
||||
'rank_zscore', # 排名的 Z-Score
|
||||
'momentum_3m', # 3分钟动量
|
||||
'momentum_5m', # 5分钟动量
|
||||
'limit_up_ratio', # 涨停占比
|
||||
],
|
||||
|
||||
# 训练配置
|
||||
'batch_size': 4096,
|
||||
'epochs': 100,
|
||||
'learning_rate': 3e-4,
|
||||
'weight_decay': 1e-5,
|
||||
'gradient_clip': 1.0,
|
||||
|
||||
# 早停配置
|
||||
'patience': 15,
|
||||
'min_delta': 1e-6,
|
||||
|
||||
# 模型配置(小型 LSTM)
|
||||
'model': {
|
||||
'n_features': 6,
|
||||
'hidden_dim': 32,
|
||||
'latent_dim': 4,
|
||||
'num_layers': 1,
|
||||
'dropout': 0.2,
|
||||
'bidirectional': True,
|
||||
},
|
||||
|
||||
# 标准化配置
|
||||
'clip_value': 5.0, # Z-Score 已经标准化,clip 5.0 足够
|
||||
|
||||
# 阈值配置
|
||||
'threshold_percentiles': [90, 95, 99],
|
||||
}
|
||||
|
||||
|
||||
# ==================== 数据加载 ====================
|
||||
|
||||
def load_data_by_date(data_dir: str, features: List[str]) -> Dict[str, pd.DataFrame]:
|
||||
"""按日期加载 V2 数据"""
|
||||
data_path = Path(data_dir)
|
||||
parquet_files = sorted(data_path.glob("features_v2_*.parquet"))
|
||||
|
||||
if not parquet_files:
|
||||
raise FileNotFoundError(f"未找到 V2 数据文件: {data_dir}")
|
||||
|
||||
print(f"找到 {len(parquet_files)} 个 V2 数据文件")
|
||||
|
||||
date_data = {}
|
||||
|
||||
for pf in tqdm(parquet_files, desc="加载数据"):
|
||||
date = pf.stem.replace('features_v2_', '')
|
||||
|
||||
df = pd.read_parquet(pf)
|
||||
|
||||
required_cols = features + ['concept_id', 'timestamp']
|
||||
missing_cols = [c for c in required_cols if c not in df.columns]
|
||||
if missing_cols:
|
||||
print(f"警告: {date} 缺少列: {missing_cols}, 跳过")
|
||||
continue
|
||||
|
||||
date_data[date] = df
|
||||
|
||||
print(f"成功加载 {len(date_data)} 天的数据")
|
||||
return date_data
|
||||
|
||||
|
||||
def split_data_by_date(
|
||||
date_data: Dict[str, pd.DataFrame],
|
||||
train_end: str,
|
||||
val_end: str
|
||||
) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
||||
"""按日期划分数据集"""
|
||||
train_data = {}
|
||||
val_data = {}
|
||||
test_data = {}
|
||||
|
||||
for date, df in date_data.items():
|
||||
if date <= train_end:
|
||||
train_data[date] = df
|
||||
elif date <= val_end:
|
||||
val_data[date] = df
|
||||
else:
|
||||
test_data[date] = df
|
||||
|
||||
print(f"数据集划分:")
|
||||
print(f" 训练集: {len(train_data)} 天 (<= {train_end})")
|
||||
print(f" 验证集: {len(val_data)} 天 ({train_end} ~ {val_end})")
|
||||
print(f" 测试集: {len(test_data)} 天 (> {val_end})")
|
||||
|
||||
return train_data, val_data, test_data
|
||||
|
||||
|
||||
def build_sequences_by_concept(
|
||||
date_data: Dict[str, pd.DataFrame],
|
||||
features: List[str],
|
||||
seq_len: int,
|
||||
stride: int
|
||||
) -> np.ndarray:
|
||||
"""按概念分组构建序列"""
|
||||
all_dfs = []
|
||||
for date, df in sorted(date_data.items()):
|
||||
df = df.copy()
|
||||
df['date'] = date
|
||||
all_dfs.append(df)
|
||||
|
||||
if not all_dfs:
|
||||
return np.array([])
|
||||
|
||||
combined = pd.concat(all_dfs, ignore_index=True)
|
||||
combined = combined.sort_values(['concept_id', 'date', 'timestamp'])
|
||||
|
||||
all_sequences = []
|
||||
grouped = combined.groupby('concept_id', sort=False)
|
||||
n_concepts = len(grouped)
|
||||
|
||||
for concept_id, concept_df in tqdm(grouped, desc="构建序列", total=n_concepts, leave=False):
|
||||
feature_data = concept_df[features].values
|
||||
feature_data = np.nan_to_num(feature_data, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
n_points = len(feature_data)
|
||||
for start in range(0, n_points - seq_len + 1, stride):
|
||||
seq = feature_data[start:start + seq_len]
|
||||
all_sequences.append(seq)
|
||||
|
||||
if not all_sequences:
|
||||
return np.array([])
|
||||
|
||||
sequences = np.array(all_sequences)
|
||||
print(f" 构建序列: {len(sequences):,} 条 (来自 {n_concepts} 个概念)")
|
||||
|
||||
return sequences
|
||||
|
||||
|
||||
# ==================== 数据集 ====================
|
||||
|
||||
class SequenceDataset(Dataset):
|
||||
def __init__(self, sequences: np.ndarray):
|
||||
self.sequences = torch.FloatTensor(sequences)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.sequences)
|
||||
|
||||
def __getitem__(self, idx: int) -> torch.Tensor:
|
||||
return self.sequences[idx]
|
||||
|
||||
|
||||
# ==================== 训练器 ====================
|
||||
|
||||
class EarlyStopping:
|
||||
def __init__(self, patience: int = 10, min_delta: float = 1e-6):
|
||||
self.patience = patience
|
||||
self.min_delta = min_delta
|
||||
self.counter = 0
|
||||
self.best_loss = float('inf')
|
||||
self.early_stop = False
|
||||
|
||||
def __call__(self, val_loss: float) -> bool:
|
||||
if val_loss < self.best_loss - self.min_delta:
|
||||
self.best_loss = val_loss
|
||||
self.counter = 0
|
||||
else:
|
||||
self.counter += 1
|
||||
if self.counter >= self.patience:
|
||||
self.early_stop = True
|
||||
return self.early_stop
|
||||
|
||||
|
||||
class Trainer:
|
||||
def __init__(
|
||||
self,
|
||||
model: nn.Module,
|
||||
train_loader: DataLoader,
|
||||
val_loader: DataLoader,
|
||||
config: Dict,
|
||||
device: torch.device,
|
||||
save_dir: str = 'ml/checkpoints_v2'
|
||||
):
|
||||
self.model = model.to(device)
|
||||
self.train_loader = train_loader
|
||||
self.val_loader = val_loader
|
||||
self.config = config
|
||||
self.device = device
|
||||
self.save_dir = Path(save_dir)
|
||||
self.save_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.optimizer = AdamW(
|
||||
model.parameters(),
|
||||
lr=config['learning_rate'],
|
||||
weight_decay=config['weight_decay']
|
||||
)
|
||||
|
||||
self.scheduler = CosineAnnealingWarmRestarts(
|
||||
self.optimizer, T_0=10, T_mult=2, eta_min=1e-6
|
||||
)
|
||||
|
||||
self.criterion = AnomalyDetectionLoss()
|
||||
|
||||
self.early_stopping = EarlyStopping(
|
||||
patience=config['patience'],
|
||||
min_delta=config['min_delta']
|
||||
)
|
||||
|
||||
self.use_amp = torch.cuda.is_available()
|
||||
self.scaler = torch.cuda.amp.GradScaler() if self.use_amp else None
|
||||
if self.use_amp:
|
||||
print(" ✓ 启用 AMP 混合精度训练")
|
||||
|
||||
self.history = {'train_loss': [], 'val_loss': [], 'learning_rate': []}
|
||||
self.best_val_loss = float('inf')
|
||||
|
||||
def train_epoch(self) -> float:
|
||||
self.model.train()
|
||||
total_loss = 0.0
|
||||
n_batches = 0
|
||||
|
||||
pbar = tqdm(self.train_loader, desc="Training", leave=False)
|
||||
for batch in pbar:
|
||||
batch = batch.to(self.device, non_blocking=True)
|
||||
self.optimizer.zero_grad(set_to_none=True)
|
||||
|
||||
if self.use_amp:
|
||||
with torch.cuda.amp.autocast():
|
||||
output, latent = self.model(batch)
|
||||
loss, _ = self.criterion(output, batch, latent)
|
||||
|
||||
self.scaler.scale(loss).backward()
|
||||
self.scaler.unscale_(self.optimizer)
|
||||
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.config['gradient_clip'])
|
||||
self.scaler.step(self.optimizer)
|
||||
self.scaler.update()
|
||||
else:
|
||||
output, latent = self.model(batch)
|
||||
loss, _ = self.criterion(output, batch, latent)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.config['gradient_clip'])
|
||||
self.optimizer.step()
|
||||
|
||||
total_loss += loss.item()
|
||||
n_batches += 1
|
||||
pbar.set_postfix({'loss': f"{loss.item():.4f}"})
|
||||
|
||||
return total_loss / n_batches
|
||||
|
||||
@torch.no_grad()
|
||||
def validate(self) -> float:
|
||||
self.model.eval()
|
||||
total_loss = 0.0
|
||||
n_batches = 0
|
||||
|
||||
for batch in self.val_loader:
|
||||
batch = batch.to(self.device, non_blocking=True)
|
||||
|
||||
if self.use_amp:
|
||||
with torch.cuda.amp.autocast():
|
||||
output, latent = self.model(batch)
|
||||
loss, _ = self.criterion(output, batch, latent)
|
||||
else:
|
||||
output, latent = self.model(batch)
|
||||
loss, _ = self.criterion(output, batch, latent)
|
||||
|
||||
total_loss += loss.item()
|
||||
n_batches += 1
|
||||
|
||||
return total_loss / n_batches
|
||||
|
||||
def save_checkpoint(self, epoch: int, val_loss: float, is_best: bool = False):
|
||||
model_to_save = self.model.module if hasattr(self.model, 'module') else self.model
|
||||
|
||||
checkpoint = {
|
||||
'epoch': epoch,
|
||||
'model_state_dict': model_to_save.state_dict(),
|
||||
'optimizer_state_dict': self.optimizer.state_dict(),
|
||||
'scheduler_state_dict': self.scheduler.state_dict(),
|
||||
'val_loss': val_loss,
|
||||
'config': self.config,
|
||||
}
|
||||
|
||||
torch.save(checkpoint, self.save_dir / 'last_checkpoint.pt')
|
||||
|
||||
if is_best:
|
||||
torch.save(checkpoint, self.save_dir / 'best_model.pt')
|
||||
print(f" ✓ 保存最佳模型 (val_loss: {val_loss:.6f})")
|
||||
|
||||
def train(self, epochs: int):
|
||||
print(f"\n开始训练 ({epochs} epochs)...")
|
||||
print(f"设备: {self.device}")
|
||||
print(f"模型参数量: {count_parameters(self.model):,}")
|
||||
|
||||
for epoch in range(1, epochs + 1):
|
||||
print(f"\nEpoch {epoch}/{epochs}")
|
||||
|
||||
train_loss = self.train_epoch()
|
||||
val_loss = self.validate()
|
||||
|
||||
self.scheduler.step()
|
||||
current_lr = self.optimizer.param_groups[0]['lr']
|
||||
|
||||
self.history['train_loss'].append(train_loss)
|
||||
self.history['val_loss'].append(val_loss)
|
||||
self.history['learning_rate'].append(current_lr)
|
||||
|
||||
print(f" Train Loss: {train_loss:.6f}")
|
||||
print(f" Val Loss: {val_loss:.6f}")
|
||||
print(f" LR: {current_lr:.2e}")
|
||||
|
||||
is_best = val_loss < self.best_val_loss
|
||||
if is_best:
|
||||
self.best_val_loss = val_loss
|
||||
self.save_checkpoint(epoch, val_loss, is_best)
|
||||
|
||||
if self.early_stopping(val_loss):
|
||||
print(f"\n早停触发!")
|
||||
break
|
||||
|
||||
print(f"\n训练完成!最佳验证损失: {self.best_val_loss:.6f}")
|
||||
self.save_history()
|
||||
|
||||
return self.history
|
||||
|
||||
def save_history(self):
|
||||
history_path = self.save_dir / 'training_history.json'
|
||||
with open(history_path, 'w') as f:
|
||||
json.dump(self.history, f, indent=2)
|
||||
print(f"训练历史已保存: {history_path}")
|
||||
|
||||
if HAS_MATPLOTLIB:
|
||||
self.plot_training_curves()
|
||||
|
||||
def plot_training_curves(self):
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
||||
epochs = range(1, len(self.history['train_loss']) + 1)
|
||||
|
||||
ax1 = axes[0]
|
||||
ax1.plot(epochs, self.history['train_loss'], 'b-', label='Train Loss', linewidth=2)
|
||||
ax1.plot(epochs, self.history['val_loss'], 'r-', label='Val Loss', linewidth=2)
|
||||
ax1.set_xlabel('Epoch')
|
||||
ax1.set_ylabel('Loss')
|
||||
ax1.set_title('Training & Validation Loss (V2)')
|
||||
ax1.legend()
|
||||
ax1.grid(True, alpha=0.3)
|
||||
|
||||
best_epoch = np.argmin(self.history['val_loss']) + 1
|
||||
best_val_loss = min(self.history['val_loss'])
|
||||
ax1.axvline(x=best_epoch, color='g', linestyle='--', alpha=0.7)
|
||||
ax1.scatter([best_epoch], [best_val_loss], color='g', s=100, zorder=5)
|
||||
|
||||
ax2 = axes[1]
|
||||
ax2.plot(epochs, self.history['learning_rate'], 'g-', linewidth=2)
|
||||
ax2.set_xlabel('Epoch')
|
||||
ax2.set_ylabel('Learning Rate')
|
||||
ax2.set_title('Learning Rate Schedule')
|
||||
ax2.set_yscale('log')
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig(self.save_dir / 'training_curves.png', dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
print(f"训练曲线已保存")
|
||||
|
||||
|
||||
# ==================== 阈值计算 ====================
|
||||
|
||||
@torch.no_grad()
|
||||
def compute_thresholds(
|
||||
model: nn.Module,
|
||||
data_loader: DataLoader,
|
||||
device: torch.device,
|
||||
percentiles: List[float] = [90, 95, 99]
|
||||
) -> Dict[str, float]:
|
||||
"""在验证集上计算阈值"""
|
||||
model.eval()
|
||||
all_errors = []
|
||||
|
||||
print("计算异动阈值...")
|
||||
for batch in tqdm(data_loader, desc="Computing thresholds"):
|
||||
batch = batch.to(device)
|
||||
errors = model.compute_reconstruction_error(batch, reduction='none')
|
||||
seq_errors = errors[:, -1] # 最后一个时刻
|
||||
all_errors.append(seq_errors.cpu().numpy())
|
||||
|
||||
all_errors = np.concatenate(all_errors)
|
||||
|
||||
thresholds = {}
|
||||
for p in percentiles:
|
||||
threshold = np.percentile(all_errors, p)
|
||||
thresholds[f'p{p}'] = float(threshold)
|
||||
print(f" P{p}: {threshold:.6f}")
|
||||
|
||||
thresholds['mean'] = float(np.mean(all_errors))
|
||||
thresholds['std'] = float(np.std(all_errors))
|
||||
thresholds['median'] = float(np.median(all_errors))
|
||||
|
||||
return thresholds
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='训练 V2 模型')
|
||||
parser.add_argument('--data_dir', type=str, default='ml/data_v2', help='V2 数据目录')
|
||||
parser.add_argument('--epochs', type=int, default=100)
|
||||
parser.add_argument('--batch_size', type=int, default=4096)
|
||||
parser.add_argument('--lr', type=float, default=3e-4)
|
||||
parser.add_argument('--device', type=str, default='auto')
|
||||
parser.add_argument('--save_dir', type=str, default='ml/checkpoints_v2')
|
||||
parser.add_argument('--train_end', type=str, default='2024-06-30')
|
||||
parser.add_argument('--val_end', type=str, default='2024-09-30')
|
||||
parser.add_argument('--seq_len', type=int, default=10, help='序列长度(分钟)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config = TRAIN_CONFIG.copy()
|
||||
config['batch_size'] = args.batch_size
|
||||
config['epochs'] = args.epochs
|
||||
config['learning_rate'] = args.lr
|
||||
config['train_end_date'] = args.train_end
|
||||
config['val_end_date'] = args.val_end
|
||||
config['seq_len'] = args.seq_len
|
||||
|
||||
if args.device == 'auto':
|
||||
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
else:
|
||||
device = torch.device(args.device)
|
||||
|
||||
print("=" * 60)
|
||||
print("概念异动检测模型训练 V2(Z-Score 特征)")
|
||||
print("=" * 60)
|
||||
print(f"数据目录: {args.data_dir}")
|
||||
print(f"设备: {device}")
|
||||
print(f"序列长度: {config['seq_len']} 分钟")
|
||||
print(f"批次大小: {config['batch_size']}")
|
||||
print(f"特征: {config['features']}")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 加载数据
|
||||
print("\n[1/6] 加载 V2 数据...")
|
||||
date_data = load_data_by_date(args.data_dir, config['features'])
|
||||
|
||||
# 2. 划分数据集
|
||||
print("\n[2/6] 划分数据集...")
|
||||
train_data, val_data, test_data = split_data_by_date(
|
||||
date_data, config['train_end_date'], config['val_end_date']
|
||||
)
|
||||
|
||||
# 3. 构建序列
|
||||
print("\n[3/6] 构建序列...")
|
||||
print("训练集:")
|
||||
train_sequences = build_sequences_by_concept(
|
||||
train_data, config['features'], config['seq_len'], config['stride']
|
||||
)
|
||||
print("验证集:")
|
||||
val_sequences = build_sequences_by_concept(
|
||||
val_data, config['features'], config['seq_len'], config['stride']
|
||||
)
|
||||
|
||||
if len(train_sequences) == 0:
|
||||
print("错误: 训练集为空!")
|
||||
return
|
||||
|
||||
# 4. 预处理
|
||||
print("\n[4/6] 数据预处理...")
|
||||
clip_value = config['clip_value']
|
||||
print(f" Z-Score 特征已标准化,截断: ±{clip_value}")
|
||||
|
||||
train_sequences = np.clip(train_sequences, -clip_value, clip_value)
|
||||
if len(val_sequences) > 0:
|
||||
val_sequences = np.clip(val_sequences, -clip_value, clip_value)
|
||||
|
||||
# 保存配置
|
||||
save_dir = Path(args.save_dir)
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(save_dir / 'config.json', 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
# 5. 创建数据加载器
|
||||
print("\n[5/6] 创建数据加载器...")
|
||||
train_dataset = SequenceDataset(train_sequences)
|
||||
val_dataset = SequenceDataset(val_sequences) if len(val_sequences) > 0 else None
|
||||
|
||||
print(f" 训练序列: {len(train_dataset):,}")
|
||||
print(f" 验证序列: {len(val_dataset) if val_dataset else 0:,}")
|
||||
|
||||
n_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 1
|
||||
num_workers = min(32, 8 * n_gpus) if sys.platform != 'win32' else 0
|
||||
|
||||
train_loader = DataLoader(
|
||||
train_dataset,
|
||||
batch_size=config['batch_size'],
|
||||
shuffle=True,
|
||||
num_workers=num_workers,
|
||||
pin_memory=True,
|
||||
prefetch_factor=4 if num_workers > 0 else None,
|
||||
persistent_workers=True if num_workers > 0 else False,
|
||||
drop_last=True
|
||||
)
|
||||
|
||||
val_loader = DataLoader(
|
||||
val_dataset,
|
||||
batch_size=config['batch_size'] * 2,
|
||||
shuffle=False,
|
||||
num_workers=num_workers,
|
||||
pin_memory=True,
|
||||
) if val_dataset else None
|
||||
|
||||
# 6. 训练
|
||||
print("\n[6/6] 训练模型...")
|
||||
model = TransformerAutoencoder(**config['model'])
|
||||
|
||||
if torch.cuda.device_count() > 1:
|
||||
print(f" 使用 {torch.cuda.device_count()} 张 GPU 并行训练")
|
||||
model = nn.DataParallel(model)
|
||||
|
||||
if val_loader is None:
|
||||
print("警告: 验证集为空,使用训练集的 10% 作为验证")
|
||||
split_idx = int(len(train_dataset) * 0.9)
|
||||
train_subset = torch.utils.data.Subset(train_dataset, range(split_idx))
|
||||
val_subset = torch.utils.data.Subset(train_dataset, range(split_idx, len(train_dataset)))
|
||||
train_loader = DataLoader(train_subset, batch_size=config['batch_size'], shuffle=True, num_workers=num_workers, pin_memory=True)
|
||||
val_loader = DataLoader(val_subset, batch_size=config['batch_size'], shuffle=False, num_workers=num_workers, pin_memory=True)
|
||||
|
||||
trainer = Trainer(
|
||||
model=model,
|
||||
train_loader=train_loader,
|
||||
val_loader=val_loader,
|
||||
config=config,
|
||||
device=device,
|
||||
save_dir=args.save_dir
|
||||
)
|
||||
|
||||
trainer.train(config['epochs'])
|
||||
|
||||
# 计算阈值
|
||||
print("\n[额外] 计算异动阈值...")
|
||||
best_checkpoint = torch.load(save_dir / 'best_model.pt', map_location=device)
|
||||
|
||||
# 创建新的单 GPU 模型用于计算阈值(避免 DataParallel 问题)
|
||||
threshold_model = TransformerAutoencoder(**config['model'])
|
||||
threshold_model.load_state_dict(best_checkpoint['model_state_dict'])
|
||||
threshold_model.to(device)
|
||||
threshold_model.eval()
|
||||
|
||||
thresholds = compute_thresholds(threshold_model, val_loader, device, config['threshold_percentiles'])
|
||||
|
||||
with open(save_dir / 'thresholds.json', 'w') as f:
|
||||
json.dump(thresholds, f, indent=2)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("训练完成!")
|
||||
print(f"模型保存位置: {args.save_dir}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
每日盘后运行:更新滚动基线
|
||||
|
||||
使用方法:
|
||||
python ml/update_baseline.py
|
||||
|
||||
建议加入 crontab,每天 15:30 后运行:
|
||||
30 15 * * 1-5 cd /path/to/project && python ml/update_baseline.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pickle
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from tqdm import tqdm
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from ml.prepare_data_v2 import (
|
||||
get_all_concepts, get_trading_days, compute_raw_concept_features,
|
||||
init_process_connections, CONFIG, RAW_CACHE_DIR, BASELINE_DIR
|
||||
)
|
||||
|
||||
|
||||
def update_rolling_baseline(baseline_days: int = 20):
|
||||
"""
|
||||
更新滚动基线(用于实盘检测)
|
||||
|
||||
基线 = 最近 N 个交易日每个时间片的统计量
|
||||
"""
|
||||
print("=" * 60)
|
||||
print("更新滚动基线(用于实盘)")
|
||||
print("=" * 60)
|
||||
|
||||
# 初始化连接
|
||||
init_process_connections()
|
||||
|
||||
# 获取概念列表
|
||||
concepts = get_all_concepts()
|
||||
all_stocks = list(set(s for c in concepts for s in c['stocks']))
|
||||
|
||||
# 获取最近的交易日
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=60)).strftime('%Y-%m-%d') # 多取一些
|
||||
|
||||
trading_days = get_trading_days(start_date, today)
|
||||
|
||||
if len(trading_days) < baseline_days:
|
||||
print(f"错误:交易日不足 {baseline_days} 天")
|
||||
return
|
||||
|
||||
# 只取最近 N 天
|
||||
recent_days = trading_days[-baseline_days:]
|
||||
print(f"使用 {len(recent_days)} 天数据: {recent_days[0]} ~ {recent_days[-1]}")
|
||||
|
||||
# 加载原始数据
|
||||
all_data = []
|
||||
for trade_date in tqdm(recent_days, desc="加载数据"):
|
||||
cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet')
|
||||
|
||||
if os.path.exists(cache_file):
|
||||
df = pd.read_parquet(cache_file)
|
||||
else:
|
||||
df = compute_raw_concept_features(trade_date, concepts, all_stocks)
|
||||
|
||||
if not df.empty:
|
||||
all_data.append(df)
|
||||
|
||||
if not all_data:
|
||||
print("错误:无数据")
|
||||
return
|
||||
|
||||
combined = pd.concat(all_data, ignore_index=True)
|
||||
print(f"总数据量: {len(combined):,} 条")
|
||||
|
||||
# 按概念计算基线
|
||||
baselines = {}
|
||||
|
||||
for concept_id, group in tqdm(combined.groupby('concept_id'), desc="计算基线"):
|
||||
baseline_dict = {}
|
||||
|
||||
for time_slot, slot_group in group.groupby('time_slot'):
|
||||
if len(slot_group) < CONFIG['min_baseline_samples']:
|
||||
continue
|
||||
|
||||
alpha_std = slot_group['alpha'].std()
|
||||
amt_std = slot_group['total_amt'].std()
|
||||
rank_std = slot_group['rank_pct'].std()
|
||||
|
||||
baseline_dict[time_slot] = {
|
||||
'alpha_mean': float(slot_group['alpha'].mean()),
|
||||
'alpha_std': float(max(alpha_std if pd.notna(alpha_std) else 1.0, 0.1)),
|
||||
'amt_mean': float(slot_group['total_amt'].mean()),
|
||||
'amt_std': float(max(amt_std if pd.notna(amt_std) else slot_group['total_amt'].mean() * 0.5, 1.0)),
|
||||
'rank_mean': float(slot_group['rank_pct'].mean()),
|
||||
'rank_std': float(max(rank_std if pd.notna(rank_std) else 0.2, 0.05)),
|
||||
'sample_count': len(slot_group),
|
||||
}
|
||||
|
||||
if baseline_dict:
|
||||
baselines[concept_id] = baseline_dict
|
||||
|
||||
print(f"计算了 {len(baselines)} 个概念的基线")
|
||||
|
||||
# 保存
|
||||
os.makedirs(BASELINE_DIR, exist_ok=True)
|
||||
baseline_file = os.path.join(BASELINE_DIR, 'realtime_baseline.pkl')
|
||||
|
||||
with open(baseline_file, 'wb') as f:
|
||||
pickle.dump({
|
||||
'baselines': baselines,
|
||||
'update_time': datetime.now().isoformat(),
|
||||
'date_range': [recent_days[0], recent_days[-1]],
|
||||
'baseline_days': baseline_days,
|
||||
}, f)
|
||||
|
||||
print(f"基线已保存: {baseline_file}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--days', type=int, default=20, help='基线天数')
|
||||
args = parser.parse_args()
|
||||
|
||||
update_rolling_baseline(args.days)
|
||||
683
nginx-110.42.32.207.conf
Normal file
@@ -0,0 +1,683 @@
|
||||
# ============================================================================
|
||||
# 110.42.32.207 Nginx 配置
|
||||
# API 服务<E69C8D>?- 处理所有后<E69C89>?API 和代理请<E79086>?
|
||||
#
|
||||
# 部署步骤<E6ADA5>?
|
||||
# 1. 上传到服务器: scp nginx-110.42.32.207.conf ubuntu@110.42.32.207:/tmp/
|
||||
# 2. 复制配置: sudo cp /tmp/nginx-110.42.32.207.conf /etc/nginx/sites-available/api.conf
|
||||
# 3. 启用配置: sudo ln -s /etc/nginx/sites-available/api.conf /etc/nginx/sites-enabled/
|
||||
# 4. 申请证书: sudo certbot --nginx -d api.valuefrontier.cn (或使用其他方<E4BB96>?
|
||||
# 5. 测试重载: sudo nginx -t && sudo systemctl reload nginx
|
||||
# ============================================================================
|
||||
|
||||
# WebSocket 连接升级映射
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# HTTP (端口 80) - 重定向到 HTTPS
|
||||
# ============================================================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.valuefrontier.cn 110.42.32.207;
|
||||
|
||||
# Let's Encrypt 验证
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# HTTPS (端口 443) - API 服务
|
||||
# ============================================================================
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.valuefrontier.cn 110.42.32.207;
|
||||
|
||||
# SSL 证书配置
|
||||
# 方式1: 使用 IP 证书(需要购买)
|
||||
# 方式2: 使用自签名证书(CDN 回源可以配置<E9858D>?HTTP<54>?
|
||||
# 方式3: 如果有域名指向这台服务器,用 Let's Encrypt
|
||||
#
|
||||
# 临时使用自签名证书(生产环境建议使用正式证书<E8AF81>?
|
||||
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
# -keyout /etc/nginx/ssl/server.key \
|
||||
# -out /etc/nginx/ssl/server.crt \
|
||||
# -subj "/CN=110.42.32.207"
|
||||
ssl_certificate /etc/nginx/ssl/api.valuefrontier.cn/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/api.valuefrontier.cn/privkey.pem;
|
||||
|
||||
# SSL 优化
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# 文件上传大小限制(客服图片上传需要)
|
||||
client_max_body_size 20M;
|
||||
|
||||
# ============================================
|
||||
# CORS 配置(允<EFBC88>?CDN 域名访问<E8AEBF>?
|
||||
# ============================================
|
||||
set $cors_origin 'https://valuefrontier.cn';
|
||||
|
||||
# 如果需要限制来源,取消下面注释
|
||||
# set $cors_origin '';
|
||||
# if ($http_origin ~* "^https://(www\.)?valuefrontier\.cn$") {
|
||||
# set $cors_origin $http_origin;
|
||||
# }
|
||||
|
||||
# ============================================
|
||||
# Flask API 代理(本<EFBC88>?gunicorn<72>?
|
||||
# ============================================
|
||||
location /api/ {
|
||||
# 处理 OPTIONS 预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept, Authorization, X-Requested-With, Cookie' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 86400;
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header Access-Control-Expose-Headers;
|
||||
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# 统一添加 CORS <20>?
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
# 超时配置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# WebSocket - Socket.IO (Flask)
|
||||
# ============================================
|
||||
location /socket.io/ {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 实时行情 WebSocket
|
||||
# ============================================
|
||||
|
||||
# 上交所行情(本地服务)
|
||||
location /ws/sse {
|
||||
proxy_pass http://101.43.133.214:8765;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# 深交所行情
|
||||
location /ws/szse {
|
||||
proxy_pass http://222.128.1.157:8765;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# MCP 服务(本地)
|
||||
# ============================================
|
||||
location /mcp/ {
|
||||
proxy_pass http://127.0.0.1:8900/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection '';
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
gzip off;
|
||||
add_header X-Accel-Buffering no;
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 数据服务 API 代理 (222.128.1.157)
|
||||
# ============================================
|
||||
|
||||
# 概念板块 API
|
||||
location /concept-api/ {
|
||||
# 处理 OPTIONS 预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 86400;
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 隐藏后端返回<E8BF94>?CORS 头(避免重复<E9878D>?
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header Access-Control-Expose-Headers;
|
||||
|
||||
proxy_pass http://222.128.1.157:16801/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 统一添加 CORS <20>?
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Elasticsearch API
|
||||
location /es-api/ {
|
||||
# 处理 OPTIONS 预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, HEAD' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 86400;
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 隐藏后端返回<E8BF94>?CORS 头(避免重复<E9878D>?
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header Access-Control-Expose-Headers;
|
||||
|
||||
proxy_pass http://222.128.1.157:19200/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 统一添加 CORS <20>?
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# 新闻搜索 API
|
||||
location /news-api/ {
|
||||
# 处理 OPTIONS 预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 86400;
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 隐藏后端返回<E8BF94>?CORS 头(避免重复<E9878D>?
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header Access-Control-Expose-Headers;
|
||||
|
||||
proxy_pass http://222.128.1.157:21891/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 统一添加 CORS <20>?
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
proxy_connect_timeout 90s;
|
||||
proxy_send_timeout 90s;
|
||||
proxy_read_timeout 90s;
|
||||
}
|
||||
|
||||
# 研报搜索 API
|
||||
location /report-api/ {
|
||||
# 处理 OPTIONS 预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 86400;
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 隐藏后端返回<E8BF94>?CORS 头(避免重复<E9878D>?
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header Access-Control-Expose-Headers;
|
||||
|
||||
proxy_pass http://222.128.1.157:8811/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 统一添加 CORS <20>?
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# 商品分类 API
|
||||
location /category-api/ {
|
||||
# 处理 OPTIONS 预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 86400;
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 隐藏后端返回<E8BF94>?CORS 头(避免重复<E9878D>?
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header Access-Control-Expose-Headers;
|
||||
|
||||
proxy_pass http://222.128.1.157:18827/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 统一添加 CORS <20>?
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 8 256k;
|
||||
proxy_busy_buffers_size 512k;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Bytedesk 客服系统代理 (43.143.189.195)
|
||||
# ============================================
|
||||
|
||||
location /bytedesk/ {
|
||||
proxy_pass http://43.143.189.195/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location /websocket {
|
||||
proxy_pass http://43.143.189.195/websocket;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location /chat/ {
|
||||
proxy_pass http://43.143.189.195/chat/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 替换响应内容中的 IP 地址
|
||||
sub_filter 'http://43.143.189.195' 'https://www.valuefrontier.cn';
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/css text/javascript application/javascript application/json;
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location /config/ {
|
||||
proxy_pass http://43.143.189.195/config/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass http://43.143.189.195/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 隐藏后端返回的可能冲突的头
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header X-Content-Type-Options;
|
||||
proxy_hide_header Cross-Origin-Resource-Policy;
|
||||
|
||||
# 解决 ORB 问题
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept' always;
|
||||
add_header 'Cross-Origin-Resource-Policy' 'cross-origin' always;
|
||||
|
||||
proxy_cache_valid 200 1d;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
location ^~ /file/ {
|
||||
proxy_pass http://43.143.189.195/file/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 隐藏后端返回的可能冲突的头
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header X-Content-Type-Options;
|
||||
proxy_hide_header Cross-Origin-Resource-Policy;
|
||||
|
||||
# 解决 ORB 问题
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept' always;
|
||||
add_header 'Cross-Origin-Resource-Policy' 'cross-origin' always;
|
||||
|
||||
proxy_cache_valid 200 1d;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
location /visitor/ {
|
||||
proxy_pass http://43.143.189.195/visitor/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
|
||||
# 替换 JSON 响应中的 IP 地址
|
||||
sub_filter 'http://43.143.189.195' 'https://www.valuefrontier.cn';
|
||||
sub_filter_once off;
|
||||
sub_filter_types application/json;
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location = /stomp {
|
||||
# 代理到 bytedesk 的 /websocket 端点
|
||||
proxy_pass http://43.143.189.195/websocket;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /stomp/ {
|
||||
# 代理到 bytedesk 的 /websocket 端点
|
||||
proxy_pass http://43.143.189.195/websocket;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ^~ /avatars/ {
|
||||
proxy_pass http://43.143.189.195/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 隐藏后端返回的可能冲突的头
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Access-Control-Allow-Methods;
|
||||
proxy_hide_header Access-Control-Allow-Headers;
|
||||
proxy_hide_header X-Content-Type-Options;
|
||||
proxy_hide_header Cross-Origin-Resource-Policy;
|
||||
|
||||
# 解决 ORB 问题
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept' always;
|
||||
add_header 'Cross-Origin-Resource-Policy' 'cross-origin' always;
|
||||
|
||||
proxy_cache_valid 200 1d;
|
||||
proxy_cache_bypass $http_cache_control;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
proxy_pass http://43.143.189.195/assets/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 健康检<E5BAB7>?
|
||||
# ============================================
|
||||
location /health {
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 微信域名验证
|
||||
# ============================================
|
||||
# 微信公众号网页授权域名验证
|
||||
location = /MP_verify_17Fo4JhapMw6vtNa.txt {
|
||||
return 200 '17Fo4JhapMw6vtNa';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 微信开放平台域名校验
|
||||
location = /gvQnxIQ5Rs.txt {
|
||||
return 200 'd526e9e857dbd2621e5100811972e8c5';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 默认返回 404
|
||||
# ============================================
|
||||
location / {
|
||||
return 404 '{"error": "Not Found"}';
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,9 @@
|
||||
"test": "craco test --env=jsdom",
|
||||
"eject": "react-scripts eject",
|
||||
"deploy": "bash scripts/deploy-from-local.sh",
|
||||
"deploy:cos": "node scripts/deploy-to-cos.js",
|
||||
"deploy:cos:clean": "node scripts/deploy-to-cos.js --clear",
|
||||
"deploy:cdn": "npm run build && npm run deploy:cos",
|
||||
"deploy:setup": "bash scripts/setup-deployment.sh",
|
||||
"rollback": "bash scripts/rollback-from-local.sh",
|
||||
"lint:check": "eslint . --ext=js,jsx,ts,tsx; exit 0",
|
||||
@@ -126,6 +129,7 @@
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"ajv": "^8.17.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"cos-nodejs-sdk-v5": "^2.15.4",
|
||||
"env-cmd": "^11.0.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.4.0",
|
||||
|
||||
BIN
public/avatars/agent.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/avatars/web.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
5489
public/css/style.css
Normal file
1524
public/docs.html
Normal file
BIN
public/fonts/Helvetica.woff
Normal file
1175
public/htmls/七腾机器人.html
Normal file
1154
public/htmls/商业航天北交所.html
Normal file
781
public/htmls/安保概念.html
Normal file
643
public/htmls/智谱华章.html
Normal file
800
public/htmls/航空航天线缆.html
Normal file
@@ -0,0 +1,800 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>航空航天线缆概念深度分析报告 - 价小前投研</title>
|
||||
<!-- Tailwind CSS and DaisyUI CDN -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Alpine.js CDN -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<!-- Echarts CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.3.3/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Space Mono', monospace; /* FUI thematic font */
|
||||
}
|
||||
/* Custom FUI/Glassmorphism/James Turrell inspired styles */
|
||||
.fui-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.fui-card {
|
||||
backdrop-filter: blur(16px);
|
||||
background-color: rgba(255, 255, 255, 0.08); /* Semi-transparent background */
|
||||
border: 1px solid rgba(255, 255, 255, 0.15); /* Soft white border */
|
||||
border-radius: 2.5rem; /* Extreme rounded corners */
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease-in-out; /* Smooth transitions for hover/Alpine */
|
||||
}
|
||||
.fui-card:hover {
|
||||
background-color: rgba(255, 255, 255, 0.12); /* Slightly more opaque on hover */
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 12px 48px 0 rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-2px); /* Subtle lift effect */
|
||||
}
|
||||
.fui-glow {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(139, 92, 246, 0.5) 0%, rgba(139, 92, 246, 0) 70%); /* Purple glow */
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.3;
|
||||
z-index: 0;
|
||||
animation: pulse 10s infinite alternate;
|
||||
}
|
||||
.fui-glow-1 { top: 10%; left: 5%; animation-delay: 0s; }
|
||||
.fui-glow-2 { top: 40%; right: 10%; background: radial-gradient(circle, rgba(59, 130, 246, 0.5) 0%, rgba(59, 130, 246, 0) 70%); animation-delay: 3s; } /* Blue glow */
|
||||
.fui-glow-3 { bottom: 15%; left: 20%; background: radial-gradient(circle, rgba(16, 185, 129, 0.5) 0%, rgba(16, 185, 129, 0) 70%); animation-delay: 6s; } /* Green glow */
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1) translate(0, 0); opacity: 0.3; }
|
||||
50% { transform: scale(1.2) translate(20px, -20px); opacity: 0.5; }
|
||||
100% { transform: scale(1) translate(0, 0); opacity: 0.3; }
|
||||
}
|
||||
/* Echarts specific styling to match theme */
|
||||
.chart-container {
|
||||
background-color: rgba(0, 0, 0, 0.2); /* Darker background for charts */
|
||||
border-radius: 1.5rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
height: 400px; /* Standard height for charts */
|
||||
box-shadow: inset 0 0 15px rgba(139, 92, 246, 0.2); /* Subtle inner glow */
|
||||
}
|
||||
/* Custom scrollbar for better FUI look */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 92, 246, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(139, 92, 246, 0.7);
|
||||
}
|
||||
.prose strong {
|
||||
color: #E2E8F0; /* Brighter white for strong text in prose */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-gray-900 via-blue-950 to-purple-950 min-h-screen text-gray-100 font-mono p-8 relative overflow-x-hidden">
|
||||
|
||||
<!-- FUI background elements (James Turrell inspired light washes) -->
|
||||
<div class="fui-glow fui-glow-1"></div>
|
||||
<div class="fui-glow fui-glow-2"></div>
|
||||
<div class="fui-glow fui-glow-3"></div>
|
||||
|
||||
<div class="fui-container max-w-7xl mx-auto relative z-10">
|
||||
|
||||
<header class="text-center mb-10 fui-card">
|
||||
<h1 class="text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-blue-300 drop-shadow-lg mb-4">航空航天线缆概念深度分析报告</h1>
|
||||
<p class="text-lg text-gray-300">北京价值前沿科技有限公司 AI投研agent:“价小前投研” 投研呈现</p>
|
||||
<p class="text-sm text-yellow-300 mt-2">本报告为AI合成数据,投资需谨慎。</p>
|
||||
</header>
|
||||
|
||||
<!-- Bento Grid for key insights -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div class="fui-card col-span-1 md:col-span-2 lg:col-span-3">
|
||||
<h2 class="text-3xl font-bold text-blue-300 mb-4 flex items-center"><span class="mr-3 text-purple-400 text-4xl">◎</span>概念事件:宏观驱动与市场催化</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<p class="text-lg">航空航天线缆概念近期受到市场高度关注,其背后是多重国家战略、技术进步和市场需求的叠加效应。</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
<div class="p-4 bg-white/5 rounded-2xl border border-white/10">
|
||||
<h3 class="text-xl font-semibold text-purple-300 mb-2">国家战略驱动</h3>
|
||||
<ul class="list-disc list-inside text-gray-300">
|
||||
<li>国防建设持续投入,军工特种线缆需求增长。</li>
|
||||
<li>“十四五”规划明确航天产业目标,如卫星互联网、商业航天、低空经济。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-2xl border border-white/10">
|
||||
<h3 class="text-xl font-semibold text-blue-300 mb-2">商业航天爆发式增长</h3>
|
||||
<ul class="list-disc list-inside text-gray-300">
|
||||
<li><span class="font-bold text-yellow-300">高价值量:</span>火箭线缆系统价值<span class="text-green-300">1000万元</span>。大型卫星单翼<span class="text-green-300">100万+</span>,双翼<span class="text-green-300">300-400万</span>。低轨小卫星<span class="text-green-300">100-200万</span>。</li>
|
||||
<li>发射提速与可回收技术成熟,带动耗材需求。</li>
|
||||
<li>卫星互联网组网加速,太空算力、激光通信、供电系统受益。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-2xl border border-white/10">
|
||||
<h3 class="text-xl font-semibold text-green-300 mb-2">国产替代浪潮</h3>
|
||||
<ul class="list-disc list-inside text-gray-300">
|
||||
<li>宝胜股份铝合金线缆完成国产化。</li>
|
||||
<li>华菱线缆在长征系列火箭配套中占<span class="text-green-300">70-80%</span>市场份额。</li>
|
||||
<li>预计2027年市场规模超<span class="text-green-300">百亿元</span>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-2xl border border-white/10">
|
||||
<h3 class="text-xl font-semibold text-red-300 mb-2">低空经济兴起</h3>
|
||||
<ul class="list-disc list-inside text-gray-300">
|
||||
<li>通用航空基础设施对特种电缆产生新需求。</li>
|
||||
<li>飞行器本身、低空通信、导航等基础设施均需配套线缆。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fui-card lg:col-span-2">
|
||||
<h2 class="text-3xl font-bold text-purple-300 mb-4 flex items-center"><span class="mr-3 text-blue-400 text-4xl">⌘</span>公司层面进展与布局 (华菱线缆示例)</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<ul class="list-disc list-inside text-gray-300">
|
||||
<li><span class="font-bold text-yellow-300">核心地位:</span>产品应用于长征系列火箭、神舟系列飞船、空间站等国家重点工程。</li>
|
||||
<li><span class="font-bold text-yellow-300">战略布局:</span>拟收购湖南星鑫航天新材料控制权,完善产业链,深化航空航天业务。</li>
|
||||
<li><span class="font-bold text-yellow-300">业绩亮点:</span>2025年1-2月航空航天及融合装备用电缆收入较2024年同期<span class="text-green-300">增长232.07%</span>,在手订单显著提升。</li>
|
||||
<li><span class="font-bold text-yellow-300">产业链延伸:</span>泛亚微透、中广核技、瑞华泰、肯特股份等提供高性能绝缘材料、防热材料支撑。国缆检测保障产品质量。</li>
|
||||
</ul>
|
||||
<div id="chart-aero-revenue" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fui-card col-span-1">
|
||||
<h2 class="text-3xl font-bold text-green-300 mb-4 flex items-center"><span class="mr-3 text-yellow-400 text-4xl">△</span>时间轴概要</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<ul class="list-disc list-inside text-gray-300">
|
||||
<li><span class="font-bold">2024年下半年:</span> 华菱线缆军方审计审查影响预计消除,需求恢复。</li>
|
||||
<li><span class="font-bold">2024年底:</span> 华菱线缆航空航天及融合装备用电缆在手订单达<span class="text-green-300">666.26万元</span>。</li>
|
||||
<li><span class="font-bold">2025年1-2月:</span> 华菱线缆航空航天及融合装备用电缆收入同比<span class="text-green-300">增长232.07%</span>。</li>
|
||||
<li><span class="font-bold">2025年2月底:</span> 华菱线缆航空航天及融合装备用电缆在手订单达<span class="text-green-300">1085.09万元</span>。</li>
|
||||
<li><span class="font-bold">2025年3月:</span> 华菱线缆拟收购星鑫航天公告。</li>
|
||||
<li><span class="font-bold">2025年下半年至2026年上半年:</span> 商业航天发射预计提速,订单或放量。</li>
|
||||
<li><span class="font-bold">2026年中后期:</span> SpaceX IPO预期,带动全球商业航天产业链热情。</li>
|
||||
<li><span class="font-bold">2027年:</span> 预计国防融合装备用特种线缆及组件市场需求规模超<span class="text-green-300">百亿元</span>。</li>
|
||||
</ul>
|
||||
<div id="chart-aero-orders" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fui-card mb-8">
|
||||
<h2 class="text-3xl font-bold text-orange-300 mb-4 flex items-center"><span class="mr-3 text-blue-400 text-4xl">▲</span>核心观点摘要</h2>
|
||||
<p class="text-lg text-gray-200">航空航天线缆概念正处于<span class="font-bold text-yellow-300">基本面驱动与主题炒作交织的初期爆发阶段</span>。其核心逻辑在于国家战略对军工、商业航天及低空经济的强力支持,叠加国产化替代需求和高壁垒技术特性,共同催生了对高性能特种线缆的巨大需求。未来,随着商业航天发射提速和相关政策落地,该概念具备<span class="font-bold text-green-300">显著的长期增长潜力</span>,头部企业有望率先受益。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<div class="fui-card">
|
||||
<h2 class="text-3xl font-bold text-pink-300 mb-4 flex items-center"><span class="mr-3 text-yellow-400 text-4xl">▶</span>概念的核心逻辑与市场认知分析</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<h3 class="text-2xl font-semibold text-blue-300">核心驱动力</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li><span class="font-bold text-yellow-300">国家战略与政策导向:</span>军工现代化、商业航天与卫星互联网、低空经济、C919国产化。</li>
|
||||
<li><span class="font-bold text-yellow-300">技术壁垒与高附加值:</span>耐极端环境、轻量化、高可靠性、高传输速率。华菱线缆航空航天业务<span class="text-green-300">8%收入贡献30-40%利润</span>。</li>
|
||||
<li><span class="font-bold text-yellow-300">国产替代与产业链自主可控:</span>宝胜股份国产化、华菱线缆高市场份额、收购星鑫航天强化产业链。</li>
|
||||
</ul>
|
||||
<h3 class="text-2xl font-semibold text-blue-300 mt-6">市场热度与情绪</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>新闻热度与板块效应:电缆股因商业航天概念大幅上涨,华菱线缆电话会议数据刺激市场预期。</li>
|
||||
<li>研报密集度:多份研报对核心公司给出积极预期,提升评级。</li>
|
||||
<li>公司高层积极展望:华菱线缆管理层强调“长期增长潜力”、“2025年后增速显著”。</li>
|
||||
<li>乐观情绪中的谨慎:提及军方审计审查影响短期业绩波动。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fui-card">
|
||||
<h2 class="text-3xl font-bold text-cyan-300 mb-4 flex items-center"><span class="mr-3 text-purple-400 text-4xl">▰</span>预期差分析</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<ul class="list-disc list-inside">
|
||||
<li><span class="font-bold text-yellow-300">短期业绩与长期潜力之间的张力:</span>研报乐观数据(如<span class="text-green-300">232.07%增长</span>)与路演揭示的短期军方审计审查影响形成对比。</li>
|
||||
<li><span class="font-bold text-yellow-300">核心公司与概念普涨之间的纯粹度差异:</span>市场对“电缆”或“航空航天”相关企业不加区分炒作,可能导致业务关联度不强、基本面支撑不足的公司上涨。</li>
|
||||
<li><span class="font-bold text-yellow-300">价值量与实际营收体量的差距:</span>单体设备线缆价值高昂(如火箭<span class="text-green-300">1000万元</span>),但航空航天订单小批量、定制化、长周期,营收占比可能有限。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<div class="fui-card">
|
||||
<h2 class="text-3xl font-bold text-yellow-300 mb-4 flex items-center"><span class="mr-3 text-pink-400 text-4xl">⚡</span>关键催化剂与未来发展路径</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<h3 class="text-2xl font-semibold text-blue-300">近期催化剂 (未来3-6个月)</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li><span class="font-bold text-yellow-300">军方审计审查结果及需求恢复:</span>预计<span class="text-green-300">2024年底</span>审计结束,需求恢复及“十四五规划”推动增长。</li>
|
||||
<li><span class="font-bold text-yellow-300">卫星互联网业务许可落地及组网加速:</span>直接拉动下游卫星制造和发射配套线缆需求。</li>
|
||||
<li><span class="font-bold text-yellow-300">商业航天发射任务密集进行:</span><span class="text-green-300">2025年下半年至2026年上半年</span>发射有望提速。</li>
|
||||
<li>华菱线缆收购星鑫航天进展。</li>
|
||||
<li>核心企业订单持续增长公告。</li>
|
||||
</ul>
|
||||
<h3 class="text-2xl font-semibold text-blue-300 mt-6">长期发展路径</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li><span class="font-bold text-yellow-300">卫星互联网规模化建设:</span>海量卫星内部、星间链路、地面站配套线缆需求,推动线缆向<span class="text-green-300">更小、更轻、更高频、抗辐照</span>发展。</li>
|
||||
<li><span class="font-bold text-yellow-300">低空经济全面发展:</span>eVTOL、无人机、基础设施对<span class="text-green-300">轻量化、高柔性、抗干扰</span>线缆需求。</li>
|
||||
<li>国防现代化与国产替代深化:军用装备升级对特种线缆性能要求更高,<span class="text-green-300">2027年市场规模超百亿元</span>。</li>
|
||||
<li>技术创新与材料升级:新材料、新结构、新功能线缆发展。</li>
|
||||
<li>产业链垂直整合与横向拓展:行业整合与技术外溢。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fui-card">
|
||||
<h2 class="text-3xl font-bold text-red-300 mb-4 flex items-center"><span class="mr-3 text-orange-400 text-4xl">⚠</span>潜在风险与挑战</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<h3 class="text-2xl font-semibold text-blue-300">技术风险</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>极端环境适应性挑战:超高温、超低温、高真空、高辐照等。</li>
|
||||
<li>高速数据传输与轻量化并存:技术实现难度高,光纤替代可能。</li>
|
||||
<li>产品导入不及预期:验证周期长,认证、测试、小批量生产延误。</li>
|
||||
</ul>
|
||||
<h3 class="text-2xl font-semibold text-blue-300 mt-6">商业化风险</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>市场开拓与订单波动:军工订单特殊性,军方审计审查影响。</li>
|
||||
<li>成本控制与原材料价格波动:铜、铝等成本占比高,油价、汇率影响利润。</li>
|
||||
<li>市场接受度与商业航天进度:产业早期,商业模式、发射频率、下游应用启动不确定性。</li>
|
||||
<li>高附加值但小批量:难以实现大规模量产,营收体量增长受限。</li>
|
||||
</ul>
|
||||
<h3 class="text-2xl font-semibold text-blue-300 mt-6">政策与竞争风险</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>政策不确定性:宏观政策、军工采购、商业航天扶持政策可能调整。</li>
|
||||
<li>行业竞争加剧:更多企业进入可能导致竞争激烈。</li>
|
||||
<li>国产替代的隐性挑战:国际巨头技术积累深厚,国内企业需持续追赶。</li>
|
||||
</ul>
|
||||
<h3 class="text-2xl font-semibold text-blue-300 mt-6">信息交叉验证风险</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>概念股泛滥与纯粹度问题:市场炒作可能波及业务关联度不强的公司。</li>
|
||||
<li>非直接相关信息过度解读:避免过度联想与概念混淆。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fui-card mb-8">
|
||||
<h2 class="text-3xl font-bold text-indigo-300 mb-4 flex items-center"><span class="mr-3 text-green-400 text-4xl">▚</span>产业链与核心公司深度剖析</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<h3 class="text-2xl font-semibold text-blue-300">产业链图谱</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li><span class="font-bold text-yellow-300">上游 (核心材料与部件):</span> 绝缘材料/护套材料(聚酰亚胺、含氟聚合物、XETFE、气凝胶、防热材料等),导体材料(高纯度铜、铝合金、镀银铜线)。</li>
|
||||
<li><span class="font-bold text-yellow-300">中游 (线缆设计、制造与集成):</span> 特种线缆与组件制造商(耐高温、耐低温、抗辐照、轻量化、高可靠性、高带宽等各类航空航天用电缆、光缆及其组件),线缆检测服务。</li>
|
||||
<li><span class="font-bold text-yellow-300">下游 (终端应用与客户):</span> 运载火箭、卫星、飞船、空间站、探测器、军用/民用飞机、无人机、低空飞行器。主要客户包括航天科技集团、航天科工集团、中航集团、商业航天公司等。</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-2xl font-semibold text-blue-300 mt-6 mb-4">核心玩家对比</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full text-gray-200 border border-white/10 rounded-2xl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-blue-800/30 text-blue-200 rounded-tl-2xl">公司名称</th>
|
||||
<th class="bg-blue-800/30 text-blue-200">核心业务/产品</th>
|
||||
<th class="bg-blue-800/30 text-blue-200">航空航天线缆相关性</th>
|
||||
<th class="bg-blue-800/30 text-blue-200">竞争优势/亮点</th>
|
||||
<th class="bg-blue-800/30 text-blue-200 rounded-tr-2xl">潜在风险/挑战</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-green-300">华菱线缆</td>
|
||||
<td>特种电缆,包括宇航员出舱用脐带电缆、高温导线、展收电缆。</td>
|
||||
<td>核心航空航天线缆供应商,产品应用于火箭、飞船、空间站。</td>
|
||||
<td>国家队核心供应商,高价值产品独家供应,拟收购星鑫航天完善产业链。</td>
|
||||
<td>军工订单波动,军方审计审查影响短期毛利率。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">通光线缆</td>
|
||||
<td>光纤光缆、输电线缆、装备线缆(含航空航天用耐高温电缆)。</td>
|
||||
<td>航空航天用耐高温电缆供应商。</td>
|
||||
<td>中航集团、航天科技集团等合格供应商,客户资源优质。</td>
|
||||
<td>航空航天业务销售额占比小,纯粹度不高。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">宝胜股份</td>
|
||||
<td>线缆及电气线路。</td>
|
||||
<td>轻量化铝合金复合绝缘航空航天线缆完成国产化。</td>
|
||||
<td>具备国产替代能力。</td>
|
||||
<td>具体业务规模和盈利能力信息较少。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">全信股份</td>
|
||||
<td>线缆组件。</td>
|
||||
<td>线缆组件产品以航空航天为主。</td>
|
||||
<td>业务聚焦航空航天,相关生产项目已达可使用状态。</td>
|
||||
<td>具体产品细节和市场份额信息不足。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">泛亚微透</td>
|
||||
<td>高性能薄膜、气凝胶、高性能线缆(参股公司)。</td>
|
||||
<td>提供航空航天线缆用聚酰亚胺/含氟聚合物绝缘复合薄膜、气凝胶等材料。</td>
|
||||
<td>位于产业链上游,技术壁垒高。</td>
|
||||
<td>材料环节,受下游线缆厂商需求影响,具体营收占比未详。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">中广核技</td>
|
||||
<td>辐照交联聚乙烯-四氟乙烯共聚物(XETFE)电缆料。</td>
|
||||
<td>XETFE电缆料可用于航空航天线缆生产。</td>
|
||||
<td>提供关键技术和材料。</td>
|
||||
<td>业务范围广,航空航天线缆材料占比不高,纯粹度低。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">瑞华泰</td>
|
||||
<td>特种涂氟高性能聚酰亚胺复合膜。</td>
|
||||
<td>专注于“航空航天线缆同特种涂氟高性能聚酰亚胺复合膜产业化项目”。</td>
|
||||
<td>聚焦高壁垒材料。</td>
|
||||
<td>项目进展和市场化程度需持续关注。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">国缆检测</td>
|
||||
<td>线缆检测服务。</td>
|
||||
<td>拥有航空航天线缆检测实验室。</td>
|
||||
<td>受益于行业高标准和产品放量。</td>
|
||||
<td>业务模式相对单一,增长空间受限于检测需求。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">华通线缆</td>
|
||||
<td>电力电缆、油服装备、数据中心电缆等。</td>
|
||||
<td>拥有“航空航天用复合电缆”专利。</td>
|
||||
<td>(专利层面)</td>
|
||||
<td>路演主要聚焦通用线缆市场,未直接提及航空航天业务进展,纯粹度不高。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fui-card mb-8">
|
||||
<h2 class="text-3xl font-bold text-purple-300 mb-4 flex items-center"><span class="mr-3 text-orange-400 text-4xl">☑</span>综合结论与投资启示</h2>
|
||||
<div class="prose prose-invert text-gray-200">
|
||||
<h3 class="text-2xl font-semibold text-blue-300">综合结论</h3>
|
||||
<p>航空航天线缆概念当前正处于<span class="font-bold text-yellow-300">基本面驱动与主题炒作并存的阶段</span>。国家战略层面的强烈支持、商业航天的爆发式增长、国防现代化建设以及国产化替代的紧迫需求,共同构成了该概念坚实的基本面支撑。部分头部企业已展现出明确的业务增长和战略布局,印证了行业的高成长性。长期来看,其发展前景确定性强,但短期内需要警惕市场情绪过热和非核心标的带来的回调风险。</p>
|
||||
|
||||
<h3 class="text-2xl font-semibold text-blue-300 mt-6">最具投资价值的细分环节或方向</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li><span class="font-bold text-yellow-300">高壁垒、高附加值的特种线缆制造头部企业:</span> 拥有核心技术、客户积累、产品毛利率高,且受益于国产替代和新领域的拓展(卫星互联网、低空经济)。<span class="text-green-300">华菱线缆(001208)</span>作为国家队核心供应商,产品线缆价值量高,战略聚焦航空航天,拟通过并购完善产业链,且<span class="text-green-300">2025年1-2月航空航天及融合装备用电缆收入同比增长232.07%</span>,基本面逻辑纯粹且强劲。此外,<span class="text-green-300">全信股份(300447)</span>因业务聚焦航空航天线缆组件,也具备较高纯度。</li>
|
||||
<li><span class="font-bold text-yellow-300">高性能、独特性上游材料供应商:</span> 航空航天线缆的性能决定于其核心材料。提供高技术壁垒、稀缺性绝缘材料、防热材料的企业,将持续受益于线缆技术的升级和放量。例如,<span class="text-green-300">泛亚微透(688386)</span>提供聚酰亚胺/含氟聚合物绝缘复合薄膜等;<span class="text-green-300">瑞华泰(688323)</span>专注于特种涂氟高性能聚酰亚胺复合膜;<span class="text-green-300">中广核技(000881)</span>提供XETFE电缆料。</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-2xl font-semibold text-blue-300 mt-6">需要重点跟踪和验证的关键指标</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<li>头部公司航空航天业务的财务数据:重点关注华菱线缆、全信股份等公司航空航天线缆业务的<span class="font-bold text-yellow-300">营收占比、毛利率变化及在手订单金额与增速</span>。</li>
|
||||
<li>军工订单审计审查进度与需求恢复情况:持续跟踪军方审计审查的结束时间及其对公司航空航天线缆订单和业绩的实际影响。</li>
|
||||
<li>商业航天发射频率和卫星组网进度:关注国家和商业航天公司的火箭发射数量、低轨卫星部署速度,以及“卫星移动通信业务许可”的落地情况。</li>
|
||||
<li>低空经济相关政策落地及产业化进展:跟踪低空经济发展规划、基础设施建设投资、eVTOL等新型飞行器的商业化进展。</li>
|
||||
<li>国产替代关键产品的渗透率和市场份额:关注宝胜股份等公司国产化产品的市场导入情况,以及国产航空航天线缆在整体市场中的份额提升速度。</li>
|
||||
<li>技术创新和标准制定:关注新型耐极端环境材料、轻量化、高带宽线缆的研发进展和技术突破。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fui-card mb-8">
|
||||
<h2 class="text-3xl font-bold text-yellow-300 mb-4 flex items-center"><span class="mr-3 text-red-400 text-4xl">📈</span>近期股票异动分析 (涨幅 > 5% 且与概念相关度高)</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full text-gray-200 border border-white/10 rounded-2xl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-red-800/30 text-red-200 rounded-tl-2xl">股票名称</th>
|
||||
<th class="bg-red-800/30 text-red-200">代码</th>
|
||||
<th class="bg-red-800/30 text-red-200">涨幅</th>
|
||||
<th class="bg-red-800/30 text-red-200">交易日期</th>
|
||||
<th class="bg-red-800/30 text-red-200 rounded-tr-2xl">核心涨幅原因概述</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr x-data="{ expanded: false }">
|
||||
<td class="text-green-300">晨光电缆</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=920639" target="_blank" class="text-blue-400 hover:text-blue-200">920639</a></td>
|
||||
<td class="text-green-400">9.01%</td>
|
||||
<td>2025-12-11</td>
|
||||
<td>
|
||||
<div :class="{ 'line-clamp-2': !expanded }" class="whitespace-normal">
|
||||
商业航天快速发展(SpaceX IPO预期,火箭/卫星线缆高价值量),华菱线缆数据刺激市场。电缆板块整体走强,市场情绪和资金推动。北证市场活跃度提升。
|
||||
</div>
|
||||
<button @click="expanded = !expanded" class="text-blue-400 hover:underline text-sm mt-1">
|
||||
<span x-show="!expanded">展开</span>
|
||||
<span x-show="expanded">收起</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-data="{ expanded: false }">
|
||||
<td class="text-green-300">再升科技</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=603601" target="_blank" class="text-blue-400 hover:text-blue-200">603601</a></td>
|
||||
<td class="text-green-400">10.0%</td>
|
||||
<td>2025-07-04</td>
|
||||
<td>
|
||||
<div :class="{ 'line-clamp-2': !expanded }" class="whitespace-normal">
|
||||
央媒报道“国产飞机棉”打破垄断并配套C919/SpaceX,稀缺性重估。公司是国内唯一能量产航空级超细玻璃纤维棉民企,已批量配套C919,并小批量供货SpaceX Falcon 9隔热层。
|
||||
</div>
|
||||
<button @click="expanded = !expanded" class="text-blue-400 hover:underline text-sm mt-1">
|
||||
<span x-show="!expanded">展开</span>
|
||||
<span x-show="expanded">收起</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-data="{ expanded: false }">
|
||||
<td class="text-green-300">爱乐达</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=300696" target="_blank" class="text-blue-400 hover:text-blue-200">300696</a></td>
|
||||
<td class="text-green-400">20.0%</td>
|
||||
<td>2025-08-04</td>
|
||||
<td>
|
||||
<div :class="{ 'line-clamp-2': !expanded }" class="whitespace-normal">
|
||||
建军98周年“新型航空装备小批量生产”信号,叠加商业航天(火箭整流罩)、低空经济政策(无人机、eVTOL结构件)催化。公司主营航空零部件精密制造,深度参与C919、军用五代机及商用航天火箭供应链。
|
||||
</div>
|
||||
<button @click="expanded = !expanded" class="text-blue-400 hover:underline text-sm mt-1">
|
||||
<span x-show="!expanded">展开</span>
|
||||
<span x-show="expanded">收起</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-data="{ expanded: false }">
|
||||
<td class="text-green-300">球冠电缆</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=920682" target="_blank" class="text-blue-400 hover:text-blue-200">920682</a></td>
|
||||
<td class="text-green-400">6.44%</td>
|
||||
<td>2025-12-11</td>
|
||||
<td>
|
||||
<div :class="{ 'line-clamp-2': !expanded }" class="whitespace-normal">
|
||||
商业航天、消费及燃气轮机等热点领域驱动电缆板块走强。SpaceX IPO预期、华菱线缆电话会议数据带动市场对商业航天电缆概念的追捧。北交所市场活跃。
|
||||
</div>
|
||||
<button @click="expanded = !expanded" class="text-blue-400 hover:underline text-sm mt-1">
|
||||
<span x-show="!expanded">展开</span>
|
||||
<span x-show="expanded">收起</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-data="{ expanded: false }">
|
||||
<td class="text-green-300">高华科技</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=688539" target="_blank" class="text-blue-400 hover:text-blue-200">688539</a></td>
|
||||
<td class="text-green-400">11.39%</td>
|
||||
<td>2025-08-15</td>
|
||||
<td>
|
||||
<div :class="{ 'line-clamp-2': !expanded }" class="whitespace-normal">
|
||||
商业航天概念关注度上升,公司作为传感器供应商,与蓝箭航天、中科宇航等多家商业航天公司合作。产品应用于运载火箭及航空领域(飞行控制、机电系统)。
|
||||
</div>
|
||||
<button @click="expanded = !expanded" class="text-blue-400 hover:underline text-sm mt-1">
|
||||
<span x-show="!expanded">展开</span>
|
||||
<span x-show="expanded">收起</span>
|
||||
</button>
|
||||
</tr>
|
||||
<tr x-data="{ expanded: false }">
|
||||
<td class="text-green-300">航天电器</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002025" target="_blank" class="text-blue-400 hover:text-blue-200">002025</a></td>
|
||||
<td class="text-green-400">5.32%</td>
|
||||
<td>2025-06-25</td>
|
||||
<td>
|
||||
<div :class="{ 'line-clamp-2': !expanded }" class="whitespace-normal">
|
||||
军工板块整体走强,商业航天领域利好消息集中释放(2025下半年至2026上半年发射提速,政策支持,成本下降),公司治理结构稳定。军事航天领域重视度提升。
|
||||
</div>
|
||||
<button @click="expanded = !expanded" class="text-blue-400 hover:underline text-sm mt-1">
|
||||
<span x-show="!expanded">展开</span>
|
||||
<span x-show="expanded">收起</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-data="{ expanded: false }">
|
||||
<td class="text-green-300">广联航空</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=300900" target="_blank" class="text-blue-400 hover:text-blue-200">300900</a></td>
|
||||
<td class="text-green-400">10.96%</td>
|
||||
<td>2025-12-05</td>
|
||||
<td>
|
||||
<div :class="{ 'line-clamp-2': !expanded }" class="whitespace-normal">
|
||||
公司披露在商业航天领域战略定位(运载火箭关键结构件+航天器配套组件),明确业务矩阵和发展路径。市场情绪与商业航天板块联动,以及“人形机器人”次级催化。
|
||||
</div>
|
||||
<button @click="expanded = !expanded" class="text-blue-400 hover:underline text-sm mt-1">
|
||||
<span x-show="!expanded">展开</span>
|
||||
<span x-show="expanded">收起</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fui-card mb-8">
|
||||
<h2 class="text-3xl font-bold text-teal-300 mb-4 flex items-center"><span class="mr-3 text-red-400 text-4xl">◫</span>核心概念股票列表</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full text-gray-200 border border-white/10 rounded-2xl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bg-teal-800/30 text-teal-200 rounded-tl-2xl">股票名称</th>
|
||||
<th class="bg-teal-800/30 text-teal-200">股票代码</th>
|
||||
<th class="bg-teal-800/30 text-teal-200">关联理由</th>
|
||||
<th class="bg-teal-800/30 text-teal-200 rounded-tr-2xl">标签</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-green-300">华菱线缆</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=001208" target="_blank" class="text-blue-400 hover:text-blue-200">001208</a></td>
|
||||
<td>公司产品包括地面发射场用点火缆和火箭本体用高温导线(公司供应产品价值量在150-300万);扁平柔性展收电缆(太阳翼,国内为独家供应)</td>
|
||||
<td>航天线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">通光线缆</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=300265" target="_blank" class="text-blue-400 hover:text-blue-200">300265</a></td>
|
||||
<td>公司产品包括航空航天用耐高温电缆,公司是中航集团、航天科技集团、航天科工集团主要合格供应商</td>
|
||||
<td>航天线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">宝胜股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600973" target="_blank" class="text-blue-400 hover:text-blue-200">600973</a></td>
|
||||
<td>公司航空航天及电气线路方面,轻量化铝合金复合绝缘航空航天线缆完成产品国产化</td>
|
||||
<td>航天线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">华通线缆</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=605196" target="_blank" class="text-blue-400 hover:text-blue-200">605196</a></td>
|
||||
<td>实用新型专利授权:航空航天用复合电缆</td>
|
||||
<td>航天线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">全信股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=300447" target="_blank" class="text-blue-400 hover:text-blue-200">300447</a></td>
|
||||
<td>公司线缆组件产品以航空航天为主;公司“航空航天用高性能线缆及轨道交通用数据线缆生产项目”已达到可使用状态</td>
|
||||
<td>航天线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">泛亚微透</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=688386" target="_blank" class="text-blue-400 hover:text-blue-200">688386</a></td>
|
||||
<td>公司的航空航天线缆用聚酰亚胺/含氟聚合物绝缘复合薄膜、气凝胶以及参股公司的高性能线缆等产品均已成功用于航空航天等领域</td>
|
||||
<td>航天线缆材料</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">中广核技</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000881" target="_blank" class="text-blue-400 hover:text-blue-200">000881</a></td>
|
||||
<td>公司开发的辐照交联聚乙烯-四氟乙烯共聚物(XETFE)电缆料可用于航空航天线缆的生产</td>
|
||||
<td>航天线缆材料</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">瑞华泰</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=688323" target="_blank" class="text-blue-400 hover:text-blue-200">688323</a></td>
|
||||
<td>航空航天线缆同特种涂氟高性能聚酰亚胺复合膜产业化项目</td>
|
||||
<td>航天线缆材料</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-green-300">国缆检测</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=301289" target="_blank" class="text-blue-400 hover:text-blue-200">301289</a></td>
|
||||
<td>航空航天线缆检测实验室</td>
|
||||
<td>检测</td>
|
||||
</tr>
|
||||
<!-- Other Cable Companies - listed as per input, but styled differently to reflect lower direct relevance -->
|
||||
<tr>
|
||||
<td class="text-gray-400">远东股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600869" target="_blank" class="text-blue-400 hover:text-blue-200">600869</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">亨通光电</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600487" target="_blank" class="text-blue-400 hover:text-blue-200">600487</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">起帆电缆</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=605222" target="_blank" class="text-blue-400 hover:text-blue-200">605222</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">汉缆股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002498" target="_blank" class="text-blue-400 hover:text-blue-200">002498</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">万马股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002276" target="_blank" class="text-blue-400 hover:text-blue-200">002276</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">太阳电缆</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002300" target="_blank" class="text-blue-400 hover:text-blue-200">002300</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">杭电股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=603618" target="_blank" class="text-blue-400 hover:text-blue-200">603618</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">中超控股</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002471" target="_blank" class="text-blue-400 hover:text-blue-200">002471</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">东方电缆</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=603606" target="_blank" class="text-blue-400 hover:text-blue-200">603606</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">中辰股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=300933" target="_blank" class="text-blue-400 hover:text-blue-200">300933</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">久盛电气</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=301082" target="_blank" class="text-blue-400 hover:text-blue-200">301082</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-gray-400">远程股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002692" target="_blank" class="text-blue-400 hover:text-blue-200">002692</a></td>
|
||||
<td>未明确提及航空航天线缆具体业务,但为线缆行业公司</td>
|
||||
<td>其他线缆</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// Echarts for华菱线缆收入增长
|
||||
var chartDom1 = document.getElementById('chart-aero-revenue');
|
||||
var myChart1 = echarts.init(chartDom1);
|
||||
var option1;
|
||||
|
||||
option1 = {
|
||||
title: {
|
||||
text: '华菱线缆航空航天业务收入增长 (2025年1-2月 vs 2024年同期)',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: '#ddd'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: '{b}: {c}%',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['2025年1-2月同比'],
|
||||
axisLabel: {
|
||||
color: '#aaa'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '增长率 (%)',
|
||||
axisLabel: {
|
||||
formatter: '{value} %',
|
||||
color: '#aaa'
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(255,255,255,0.1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '增长率',
|
||||
type: 'bar',
|
||||
data: [232.07],
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(
|
||||
0, 0, 0, 1,
|
||||
[
|
||||
{offset: 0, color: '#8b5cf6'}, // purple-500
|
||||
{offset: 1, color: '#3b82f6'} // blue-500
|
||||
]
|
||||
)
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: '{c}%',
|
||||
color: '#fff'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
myChart1.setOption(option1);
|
||||
|
||||
// Echarts for华菱线缆在手订单金额
|
||||
var chartDom2 = document.getElementById('chart-aero-orders');
|
||||
var myChart2 = echarts.init(chartDom2);
|
||||
var option2;
|
||||
|
||||
option2 = {
|
||||
title: {
|
||||
text: '华菱线缆航空航天业务在手订单金额',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: '#ddd'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: '{b}: {c} 万元',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['2024年底', '2025年2月底'],
|
||||
axisLabel: {
|
||||
color: '#aaa'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额 (万元)',
|
||||
axisLabel: {
|
||||
formatter: '{value} 万元',
|
||||
color: '#aaa'
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(255,255,255,0.1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '订单金额',
|
||||
type: 'bar',
|
||||
data: [666.26, 1085.09],
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(
|
||||
0, 0, 0, 1,
|
||||
[
|
||||
{offset: 0, color: '#10b981'}, // emerald-500
|
||||
{offset: 1, color: '#06b6d4'} // cyan-500
|
||||
]
|
||||
)
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: '{c}万元',
|
||||
color: '#fff'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
myChart2.setOption(option2);
|
||||
|
||||
// Resize charts on window resize
|
||||
window.addEventListener('resize', function() {
|
||||
myChart1.resize();
|
||||
myChart2.resize();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
919
public/htmls/英伟达H200.html
Normal file
@@ -0,0 +1,919 @@
|
||||
北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现,本报告为AI合成数据,投资需谨慎。
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>英伟达H200概念深度分析报告</title>
|
||||
<!-- Tailwind CSS and DaisyUI -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
/* Custom styles for FUI/Glassmorphism effect */
|
||||
body {
|
||||
font-family: 'Inter', sans-serif; /* A clean, modern font */
|
||||
background: linear-gradient(135deg, #2a003f 0%, #0a011d 100%); /* Deep space gradient */
|
||||
color: #e0e0e0; /* Light gray for body text */
|
||||
overflow-x: hidden; /* Prevent horizontal scroll */
|
||||
}
|
||||
.glass-card {
|
||||
background-color: rgba(255, 255, 255, 0.08); /* Semi-transparent white */
|
||||
backdrop-filter: blur(20px); /* Blur effect */
|
||||
border: 1px solid rgba(255, 255, 255, 0.15); /* Subtle border */
|
||||
border-radius: 2rem; /* Extreme rounded corners */
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); /* Depth shadow */
|
||||
transition: all 0.3s ease-in-out;
|
||||
position: relative; /* For internal positioning */
|
||||
z-index: 10; /* Ensure cards are above background blobs */
|
||||
}
|
||||
.glass-card:hover {
|
||||
box-shadow: 0 12px 48px 0 rgba(0, 0, 0, 0.5), 0 0 20px rgba(236, 72, 153, 0.4); /* Hover glow */
|
||||
}
|
||||
.bento-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); /* Responsive grid */
|
||||
gap: 1.5rem;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #f0e0ff; /* Lighter purple for headings */
|
||||
}
|
||||
a {
|
||||
color: #93c5fd; /* Light blue for links */
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Echarts container sizing */
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
min-height: 450px;
|
||||
max-height: 600px; /* Adjust as needed */
|
||||
}
|
||||
/* Blob animation for header background */
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite cubic-bezier(0.6, -0.28, 0.735, 0.045);
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
/* Custom timeline style */
|
||||
.timeline-container .timeline-box {
|
||||
background-color: rgba(255, 255, 255, 0.05); /* Lighter glass effect for timeline boxes */
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.2);
|
||||
padding: 1rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.timeline-start {
|
||||
min-width: 120px; /* Ensure timeline dates have enough space */
|
||||
text-align: right;
|
||||
}
|
||||
.timeline-end {
|
||||
text-align: left;
|
||||
}
|
||||
.timeline-middle svg {
|
||||
color: #f0e0ff;
|
||||
}
|
||||
.timeline hr {
|
||||
background-color: #a78bfa; /* A vibrant purple for timeline connectors */
|
||||
}
|
||||
</style>
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<!-- Echarts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.3.3/dist/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mx-auto p-4 sm:p-8 lg:p-12">
|
||||
<!-- Header -->
|
||||
<header class="text-center mb-16 relative z-0">
|
||||
<h1 class="text-5xl lg:text-7xl font-extrabold mb-4 text-fuchsia-300 drop-shadow-lg animate-pulse">
|
||||
英伟达H200概念深度分析报告
|
||||
</h1>
|
||||
<p class="text-lg text-gray-400 mb-8 tracking-wide">
|
||||
北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现
|
||||
</p>
|
||||
<p class="text-md text-red-400 font-bold mb-4">
|
||||
本报告为AI合成数据,投资需谨慎。
|
||||
</p>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none z-[-1]">
|
||||
<div class="w-80 h-80 lg:w-96 lg:h-96 bg-fuchsia-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob"></div>
|
||||
<div class="w-80 h-80 lg:w-96 lg:h-96 bg-blue-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
<div class="w-80 h-80 lg:w-96 lg:h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<main class="grid grid-cols-1 gap-6 mb-16 relative z-10">
|
||||
<!-- Section 0: 概念洞察:英伟达H200 -->
|
||||
<section class="glass-card p-8 shadow-xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-fuchsia-300">0. 概念洞察:英伟达H200</h2>
|
||||
<div x-data="{ open: false }" class="mb-6">
|
||||
<button @click="open = !open" class="btn btn-ghost text-lg text-blue-300 hover:underline">
|
||||
点击查看 / 隐藏 概念事件时间轴
|
||||
<span x-show="!open" class="ml-2">▼</span>
|
||||
<span x-show="open" class="ml-2">▲</span>
|
||||
</button>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 transform -translate-y-4" x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100 transform translate-y-0" x-transition:leave-end="opacity-0 transform -translate-y-4" class="timeline-container mt-4 p-4 bg-base-200 rounded-lg">
|
||||
<!-- Timeline Content -->
|
||||
<ul class="timeline timeline-vertical lg:timeline-horizontal">
|
||||
<li>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2023年11月</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">英伟达H200芯片首次发布。</div>
|
||||
<hr class="bg-primary"/>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary"/>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2024年Q1/H1</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">多个路演和新闻指出H200预计在Q1或H1量产,并优先供应AWS、微软等云服务商。存在矛盾信息:部分研报提及H200通用版本在1H 2023已出货,或2024年晚些时候发布。</div>
|
||||
<hr class="bg-primary"/>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary"/>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2024年5月-11月</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">多个路演确认H200已开始送样测试、量产并出货,成为主力产品,销量环比增长至“两位数”,已上线AWS、Google Cloud、OVH等云实例。</div>
|
||||
<hr class="bg-primary"/>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary"/>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2024年12月1日</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">H200 NVL(PCIe版本)在2024美国超级计算机大会(SC24)上发布。</div>
|
||||
<hr class="bg-primary"/>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary"/>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2025年6月</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">新闻“西部计算机”分析指出H200在25年6月已停产,潜在供应量有限。这与之前量产和主力出货的说法存在重大冲突,可能是指特定版本或短期生产周期。</div>
|
||||
<hr class="bg-primary"/>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary"/>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2025年11月22日</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">路透社援引消息透露,美国政府正考虑批准向中国出口英伟达H200人工智能芯片。</div>
|
||||
<hr class="bg-primary"/>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary"/>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2025年12月8日</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">特朗普在社交平台发文宣布美国将允许英伟达向中国出售H200,销售条件包括25%销售额支付美国政府,且仅供应“经批准的客户”。</div>
|
||||
<hr class="bg-primary"/>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary"/>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2025年12月9日</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">中国外交部发言人郭嘉昆回应“注意到有关报道。中方一贯主张中美通过合作实现互利共赢”。</div>
|
||||
<hr class="bg-primary"/>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary"/>
|
||||
<div class="timeline-start timeline-box text-sm sm:text-base">2025年12月10日</div>
|
||||
<div class="timeline-middle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-5 w-5"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box bg-gradient-to-r from-purple-800 to-indigo-800 text-white shadow-lg p-4 rounded-xl text-sm sm:text-base">《金融时报》报道称中方正在考虑限制获取英伟达的H200芯片,英伟达CEO黄仁勋也表示“不确定中国是否会接受该公司的H200人工智能芯片”。</div>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- End Timeline Content -->
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-300 mt-4 leading-relaxed text-sm sm:text-base">
|
||||
英伟达H200芯片的概念事件围绕其作为下一代AI算力核心的发布、性能迭代、以及对华销售政策的复杂演变。
|
||||
H200作为英伟达“性能第二强”的芯片,其强大的算力优势以及美国政府对其对华销售政策的潜在解禁,是点燃市场关注的主要事件。特别是在中国市场,H200的进入被视为缓解国内AI算力短缺,推动AIDC建设提速的关键因素。
|
||||
</p>
|
||||
<h3 class="text-2xl font-bold mt-8 mb-4 text-fuchsia-300">核心观点摘要</h3>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
英伟达H200概念目前处于<strong class="text-red-300">高预期与高不确定性并存</strong>的阶段。其核心驱动力在于全球AI大模型对算力持续增长的强劲需求以及H200自身显著的性能提升。然而,围绕H200在中国的市场前景,受制于<strong class="text-red-300">复杂的政策博弈和潜在的成本挑战</strong>,存在巨大的预期差和风险。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Section 1: 概念的核心逻辑与市场认知分析 -->
|
||||
<section class="glass-card p-8 shadow-xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-fuchsia-300">1. 概念的核心逻辑与市场认知分析</h2>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">核心驱动力</h3>
|
||||
<div class="bento-grid mb-6">
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">AI模型驱动算力需求</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">LLM和生成式AI模型对算力需求爆炸式增长,H200作为NVIDIA数据中心AI核心,提供高达1.7倍推理性能提升,能在数小时内完成LLM微调。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">H200显著的技术升级</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">相较H100,H200在HBM3e内存(141GB vs 80GB)、内存带宽(+40%)和能效(推理速度提升1倍,能耗降低50%)方面大幅提升,优化Perfill和Decode阶段效率。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">政策放宽短期催化</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">美国政府考虑并宣布允许H200对华销售,被市场解读为限制松动,有望缓解国内AI算力瓶颈,推动AIDC建设提速,提振相关厂商资本开支。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">市场热度与情绪</h3>
|
||||
<ul class="list-disc pl-5 text-gray-300 mb-6 space-y-2 text-sm sm:text-base">
|
||||
<li><strong class="text-blue-300">新闻热度:</strong> H200对华销售政策、性能对比和国内AI建设影响的分析显示极高市场关注度。</li>
|
||||
<li><strong class="text-blue-300">研报密集度:</strong> 多份研报将其视为英伟达在生成式AI时代的核心产品,对其技术规格、市场定位和未来影响进行了深入分析。</li>
|
||||
<li><strong class="text-blue-300">路演反馈:</strong> 英伟达官方在财报电话会议中多次提及H200的出货量显著提升,成为推广速度最快的单品,并已向大型CSP出货,表明其在全球市场已获得认可。</li>
|
||||
<li><strong class="text-blue-300">股票市场反应:</strong> <span class="text-green-400">科华数据 (+6.95%)</span>、<span class="text-green-400">杰美特 (+5.90%)</span> 受消息刺激上涨。然而,也有如<span class="text-red-400">鸿日达 (+13.32%)</span>这类公司,其上涨是基于市场对“液冷板”概念的过度投机和想象,而非明确的业务关联,反映出市场情绪的非理性部分。</li>
|
||||
<li><strong class="text-red-300">情绪分歧:</strong> 尽管有政策利好,但中方考虑限制、英伟达CEO对中国市场接受度的不确定性、以及附带的25%销售分成费用等因素,使得市场对H200在中国的实际渗透能力持<strong class="text-red-300">谨慎态度</strong>。</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">预期差分析</h3>
|
||||
<div class="bento-grid mb-6">
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">政策双刃剑与落地难点</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">市场对美国批准H200对华销售普遍持乐观态度,认为将“有力支持国内AI芯片供应”,推动“国内AIDC建设提速”。然而,新闻中也明确指出“<strong class="text-red-300">中方正在考虑限制获取英伟达的H200芯片</strong>”,且英伟达CEO黄仁勋本人也“<strong class="text-red-300">不确定中国是否会接受</strong>”。此外,“对每颗芯片收取一定费用,相应芯片销售额的<strong class="text-red-300">25%将支付给美国政府</strong>”的条件,将大幅推高H200在中国的采购成本,削弱其性价比。这些因素共同构成一个巨大的预期差:即使美国放开,中国市场是否会大规模接受,仍是未知数。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">H200产品生命周期与供需矛盾</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">路演信息显示H200在2024年Q2已开始量产并成为主力产品,但新闻中“西部计算机”分析指出H200在<strong class="text-red-300">25年6月已停产</strong>,潜在供应量有限。这两种说法存在明显矛盾,可能意味着H200作为过渡产品,其生命周期会比市场预期的短,或者供应存在区域性差异。若果真停产,则其对中国市场的长期影响将大打折扣。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">性能宣传与实际应用差异</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">研报和路演中H200的性能指标(HBM容量、带宽、TDP、FP8/FP16算力)存在多处矛盾。此外,“西部计算机”认为H200主要场景还在于训练,对推理市场影响不大,且“<strong class="text-red-300">H200在国内推理模型的推理任务中,跟国产卡比,不具备性价比优势</strong>”,这与部分路演中强调其推理性能提升的观点形成对比,提示其在国内市场的应用可能会有侧重。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">国产替代的决心与进展</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">市场在关注H200的同时,可能低估了中国在发展国产AI芯片方面的决心和进展。<strong class="text-red-300">华为昇腾910B</strong>在部分场景下性能已经优于H200,且价格更具竞争力。海光信息也明确表示将强化性能对标与生态兼容,突出安全合规与性价比优势,以巩固竞争地位。这表明即使H200入华,也将面临来自本土厂商的激烈竞争,而非简单的市场空白填充。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 2: H200核心技术规格与性能对比 -->
|
||||
<section class="glass-card p-8 shadow-xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-fuchsia-300">2. H200核心技术规格与性能对比</h2>
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">英伟达H200基本信息与性能概览</h3>
|
||||
<div class="bento-grid mb-6">
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">市场定位</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">英伟达“性能第二强”芯片;Hopper架构下AI训练和推理核心;新一代工业革命基础设施;生成式AI时代核心产品。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">发布/量产时间 (存在冲突)</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 text-sm sm:text-base">
|
||||
<li>新闻:24年H1量产。</li>
|
||||
<li>路演:2023年11月发布;2024年Q2量产/开售;2024年5月已开始送样测试并量产;H200 NVL在2024 SC24发布。也有预测2025年。</li>
|
||||
<li>研报:2024年2季度和4季度上市;2024年晚些时候发布;通用版本1H 2023出货 (存在矛盾)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">应用案例</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">GMI Cloud技术团队已部署DeepSeek R1基于H200 GPU服务器;单个HGX H200系统每秒最多处理3872个token。</p>
|
||||
<p class="text-gray-300 mt-2 text-sm sm:text-base">在气候建模中,H200用于生成式AI天气预测模型CorrDiff,将极端天气事件分辨率从25公里提升至2公里。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">性能关键指标对比</h3>
|
||||
<p class="text-gray-300 mb-4 text-sm sm:text-base">下图展示H200、H100、H20在核心性能指标上的对比,部分数据存在矛盾,图表取相对一致或主流观点。</p>
|
||||
<div id="performance-chart" class="chart-container glass-card p-4 mb-8"></div>
|
||||
|
||||
<div x-data="{ openSpecs: false }" class="mb-8">
|
||||
<button @click="openSpecs = !openSpecs" class="btn btn-ghost text-lg text-blue-300 hover:underline">
|
||||
点击查看 / 隐藏 详细技术规格 (含矛盾数据)
|
||||
<span x-show="!openSpecs" class="ml-2">▼</span>
|
||||
<span x-show="openSpecs" class="ml-2">▲</span>
|
||||
</button>
|
||||
<div x-show="openSpecs" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 transform -translate-y-4" x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100 transform translate-y-0" x-transition:leave-end="opacity-0 transform -translate-y-4" class="mt-4 p-4 bg-base-200 rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full text-gray-200 text-sm sm:text-base">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-fuchsia-300">特性</th>
|
||||
<th class="text-fuchsia-300">H200 (SXM/通用版)</th>
|
||||
<th class="text-fuchsia-300">H200 NVL (PCIe版)</th>
|
||||
<th class="text-fuchsia-300">H100</th>
|
||||
<th class="text-fuchsia-300">H20</th>
|
||||
<th class="text-fuchsia-300">数据来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>制程工艺</td>
|
||||
<td>4nm</td>
|
||||
<td>-</td>
|
||||
<td>4nm</td>
|
||||
<td>-</td>
|
||||
<td>新闻/研报</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HBM类型/容量</td>
|
||||
<td><span class="text-green-400">HBM3E (141GB)</span> <br/> <span class="text-orange-400">(研报:192GB HBM3e)</span> <br/> <span class="text-orange-400">(研报:80GB HBM3e - 疑误报)</span> <br/> <span class="text-orange-400">(路演:未来可能200GB HBM3)</span></td>
|
||||
<td><span class="text-green-400">141GB</span></td>
|
||||
<td>HBM3 (80GB)</td>
|
||||
<td>HBM3 (96GB)</td>
|
||||
<td>新闻/研报/路演</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>带宽</td>
|
||||
<td><span class="text-green-400">4.8 TB/s</span> <br/> <span class="text-orange-400">(研报:8 TB/s)</span> <br/> <span class="text-orange-400">(研报:3.35 TB/s - 疑误报)</span> <br/> <span class="text-blue-300">(比H100高40%内存带宽)</span></td>
|
||||
<td><span class="text-green-400">4.8 TB/s</span> <br/> <span class="text-blue-300">(比H100 NVL高1.2倍)</span></td>
|
||||
<td>3.35 TB/s</td>
|
||||
<td>-</td>
|
||||
<td>新闻/研报/路演</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>FP16算力</td>
|
||||
<td><span class="text-blue-300">H100=H200</span> <br/> <span class="text-orange-400">(研报:5 PFLOPS)</span> <br/> <span class="text-orange-400">(研报:2 PFLOPS - 疑误报)</span></td>
|
||||
<td>-</td>
|
||||
<td><span class="text-blue-300">5 PFLOPS</span></td>
|
||||
<td>H200是H20的13.4倍 (约0.37 PFLOPS)</td>
|
||||
<td>新闻/研报</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>FP8算力</td>
|
||||
<td><span class="text-orange-400">10 PFLOPS</span> <br/> <span class="text-orange-400">(研报:4 PFLOPS - 疑误报)</span></td>
|
||||
<td>-</td>
|
||||
<td>4 PFLOPS</td>
|
||||
<td>-</td>
|
||||
<td>研报</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>推理速度对比H100</td>
|
||||
<td><span class="text-green-400">提升1倍 / 接近2倍</span> <br/> <span class="text-blue-300">(或提升40%)</span></td>
|
||||
<td><span class="text-green-400">提升高达1.7倍</span></td>
|
||||
<td>基准 (1倍)</td>
|
||||
<td>H200是H20的13倍 / 10倍 (约0.1-0.2倍H100)</td>
|
||||
<td>路演/研报</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>功耗 (TDP)</td>
|
||||
<td><span class="text-orange-400">1000W</span> <br/> <span class="text-orange-400">(研报:700W)</span> <br/> <span class="text-blue-300">(比H100能耗降低50%)</span></td>
|
||||
<td><span class="text-green-400">600W</span></td>
|
||||
<td>700W (研报数据)</td>
|
||||
<td>-</td>
|
||||
<td>研报/路演</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-orange-400 mt-4 text-sm sm:text-base"><strong>注意:</strong> 上述表格中标记为 <span class="text-orange-400">(研报/路演数据)</span> 或 <span class="text-red-400">(疑误报)</span> 的数据点在不同信息源之间存在矛盾,请谨慎参考。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-300 leading-relaxed mt-4 text-sm sm:text-base">
|
||||
<strong>结论:</strong>H200领先于H100、H20,更优于之前的H800;更强的单卡算力在AI大模型训练、推理的Perfill阶段大幅提升;更强的HBM在推理Decode差距大幅提升。H200 NVL作为低功耗、空气冷却的PCIe版本,提供灵活配置和显著的推理性能提升。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Section 3: H200对华销售政策与市场影响 -->
|
||||
<section class="glass-card p-8 shadow-xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-fuchsia-300">3. H200对华销售政策与市场影响</h2>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">对华销售政策演变与条件</h3>
|
||||
<ul class="list-disc pl-5 text-gray-300 mb-6 space-y-2 text-sm sm:text-base">
|
||||
<li><strong class="text-blue-300">考虑阶段:</strong>2025年11月22日,路透社披露特朗普政府正考虑批准向中国出口H200芯片。</li>
|
||||
<li><strong class="text-blue-300">正式宣布:</strong>2025年12月8日,特朗普发文宣布美国允许英伟达向中国销售H200。</li>
|
||||
<li><strong class="text-red-300">销售条件:</strong>对每颗芯片收取一定费用,相应销售额的<strong class="text-red-300">25%将支付给美国政府</strong>;仅供应给“经批准的客户”。</li>
|
||||
<li><strong class="text-blue-300">适用范围:</strong>新政也适用于AMD、英特尔等公司。英伟达Blackwell和Rubin芯片不在此次交易范围内。</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">中美双方回应与不确定性</h3>
|
||||
<div class="bento-grid mb-6">
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">英伟达回应</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">“向商业客户供应H200是一种值得肯定的举措。”但CEO黄仁勋表示“不确定中国是否会接受该公司的H200人工智能芯片。”</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">中方回应</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">外交部发言人郭嘉昆表示“注意到有关报道。中方一贯主张中美通过合作实现互利共赢。”</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">中方潜在限制</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">2025年12月10日,《金融时报》报道称<strong class="text-red-300">中方正在考虑限制获取英伟达的H200芯片。</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">市场与供应链影响</h3>
|
||||
<div class="bento-grid mb-6">
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">供应状况 (存在冲突)</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 text-sm sm:text-base">
|
||||
<li>英伟达否认H100/H200短缺,表示“有足够的H100/H200来满足所有订单”。</li>
|
||||
<li>“西部计算机”分析指出H200实际库存不高,供应有限,且<strong class="text-red-300">25年6月已停产</strong>。</li>
|
||||
<li>集邦咨询预计CSP和OEM将提高需求,H200将于2024年Q3后成为NVIDIA供货主力。</li>
|
||||
<li>路演确认H200已开始量产并出货,成为主力产品。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">对国内AI建设影响</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 text-sm sm:text-base">
|
||||
<li>解禁有望推动国内经济向算力驱动转型,AIDC建设有望提速。</li>
|
||||
<li>缓解此前市场对芯片供给不足影响26年国内AI建设进度的担忧。</li>
|
||||
<li>先进算力卡再度进入国内市场,国内厂商算力投资有望回归。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">竞争格局与挑战</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 text-sm sm:text-base">
|
||||
<li>“西部计算机”认为H200主要场景在于训练,对推理市场影响不大,<strong class="text-red-300">在国内推理模型不具备性价比优势</strong>。</li>
|
||||
<li>海光信息高管回应:H200入华或加剧国内高端芯片市场竞争,但<strong class="text-red-300">25%销售分成推高采购成本,市场渗透存在挑战</strong>。</li>
|
||||
<li>国产超节点与H200采购不冲突,有望成为推理场景下算力效率优化的有效形式。</li>
|
||||
<li>华为昇腾910B在部分场景下性能优于H200,价格更具竞争力。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 4: 产业链与核心公司深度剖析 -->
|
||||
<section class="glass-card p-8 shadow-xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-fuchsia-300">4. 产业链与核心公司深度剖析</h2>
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">产业链图谱概览</h3>
|
||||
<p class="text-gray-300 mb-6 leading-relaxed text-sm sm:text-base">
|
||||
英伟达H200概念的产业链涵盖上游核心零部件、中游集成与制造以及下游应用与服务,构成一个庞大而复杂的生态系统。
|
||||
</p>
|
||||
|
||||
<div x-data="{ activeTab: 'upstream' }" class="tabs tabs-boxed mb-6 bg-base-200 rounded-xl p-2">
|
||||
<a @click="activeTab = 'upstream'" :class="{'tab-active': activeTab === 'upstream'}" class="tab text-white btn-ghost text-sm sm:text-base">上游核心零部件</a>
|
||||
<a @click="activeTab = 'midstream'" :class="{'tab-active': activeTab === 'midstream'}" class="tab text-white btn-ghost text-sm sm:text-base">中游集成与制造</a>
|
||||
<a @click="activeTab = 'downstream'" :class="{'tab-active': activeTab === 'downstream'}" class="tab text-white btn-ghost text-sm sm:text-base">下游应用与服务</a>
|
||||
</div>
|
||||
|
||||
<div x-show="activeTab === 'upstream'" class="tab-content">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">上游核心零部件</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 space-y-2 text-sm sm:text-base">
|
||||
<li><strong class="text-purple-300">HBM/先进封装:</strong> SK Hynix、Micron、三星(受益于HBM3e需求)。</li>
|
||||
<li><strong class="text-purple-300">PCB/CCL:</strong> 胜宏科技、生益科技、景旺电子、沪电股份、方正科技、鹏鼎控股、超颖电子、东山精密。</li>
|
||||
<li><strong class="text-purple-300">PCB设备:</strong> 大族数控、大族激光、芯碁微装、鼎泰高科。</li>
|
||||
<li><strong class="text-purple-300">高速互联:</strong> 立讯精密、汇聚科技、东山精密、致尚科技。</li>
|
||||
<li><strong class="text-purple-300">液冷/电源:</strong> 立讯精密、领益智造、奥海科技、奕东电子、比亚迪电子、欧陆通、东阳光、淳中科技。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div x-show="activeTab === 'midstream'" class="tab-content">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">中游集成与制造</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 space-y-2 text-sm sm:text-base">
|
||||
<li><strong class="text-purple-300">芯片设计:</strong> 英伟达。</li>
|
||||
<li><strong class="text-purple-300">代理:</strong> 弘信电子、神州数码、紫光股份、中电港、三人行。</li>
|
||||
<li><strong class="text-purple-300">组装:</strong> 工业富联、华勤技术。</li>
|
||||
<li><strong class="text-purple-300">服务器:</strong> 工业富联、华勤技术、浪潮信息、航锦科技、紫光股份。</li>
|
||||
<li><strong class="text-purple-300">光模块:</strong> 博创科技、致尚科技、光库科技、中际旭创、太辰光。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div x-show="activeTab === 'downstream'" class="tab-content">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">下游应用与服务</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 space-y-2 text-sm sm:text-base">
|
||||
<li><strong class="text-purple-300">算力中心/IDC:</strong> 鸿博股份、润建股份、大位科技、润泽科技、奥飞数据、光环新网、杭钢股份、数据港。</li>
|
||||
<li><strong class="text-purple-300">算力租赁:</strong> 宏景科技、协创数据。</li>
|
||||
<li><strong class="text-purple-300">国产AIDC配套:</strong> 潍柴重机、光迅科技、华工科技、锐捷网络。</li>
|
||||
<li><strong class="text-purple-300">UPS&HVDC:</strong> 科士达、科华数据、中恒电气。</li>
|
||||
<li><strong class="text-purple-300">数据中心交换机:</strong> 锐捷网络、星网锐捷、紫光股份、菲菱科思、盛科通信、共进股份。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold mt-8 mb-4 text-fuchsia-300">核心玩家对比与验证</h3>
|
||||
<div class="bento-grid mb-6">
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">代理商(神州数码、紫光股份等)</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">优势:成熟分销网络。风险:销售政策不确定性、25%分成降低竞争力,以及黄仁勋对中国市场接受度的疑虑。</p>
|
||||
<p class="text-red-300 mt-2 text-sm sm:text-base"><strong>证伪风险:</strong> 政策和成本挑战远超预期,代理商销售前景不确定性高。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">服务器厂商(浪潮信息、工业富联等)</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">优势:AIDC建设核心受益者,集成能力强。风险:H200供应保障、在华销售成本、国产替代方案竞争。</p>
|
||||
<p class="text-red-300 mt-2 text-sm sm:text-base"><strong>证伪风险:</strong> 供应不足或成本过高直接影响出货与利润,国产替代方案形成竞争。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">液冷/电源供应商(中恒电气、科华数据等)</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">优势:高功耗AI芯片的刚需环节,高价值量。中恒电气已通过英伟达GB200认证。验证:中恒电气、科华数据、英维克股价表现印证需求。</p>
|
||||
<p class="text-green-300 mt-2 text-sm sm:text-base"><strong>验证:</strong> AI算力增长的刚性需求,高功耗推动液冷/高阶电源成为核心基础设施。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">国产AI芯片生态相关方(海光信息、川润股份等)</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">优势:受益于国产替代战略和H200入华受阻。华为昇腾910B具竞争力。验证:川润股份(昇腾液冷配套)被资金抢筹。</p>
|
||||
<p class="text-green-300 mt-2 text-sm sm:text-base"><strong>验证:</strong> 国家信息安全战略与H200入华受阻加速国产替代进程。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 5: 潜在风险与挑战 -->
|
||||
<section class="glass-card p-8 shadow-xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-fuchsia-300">5. 潜在风险与挑战</h2>
|
||||
<div class="bento-grid mb-6">
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">技术风险</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 text-sm sm:text-base">
|
||||
<li><strong class="text-red-400">产品迭代过快:</strong> 英伟达在AI芯片领域技术迭代极快,Blackwell、Rubin等下一代架构已在规划中。H200作为Hopper架构的升级版,其领先性可能很快被自身更先进的产品(如GB200/B100)超越,从而缩短其市场生命周期。部分研报和路演中关于H200的发布和量产时间存在明显矛盾,也反映出其作为过渡产品的可能性。</li>
|
||||
<li><strong class="text-red-400">性能参数不一致性:</strong> 不同信息源(新闻、路演、研报)对H200的HBM容量(80GB到192GB)、带宽(3.35TB/s到8TB/s)、TDP(700W到1000W)和FP8/FP16算力等关键技术规格存在显著差异。这种信息不一致性增加了评估其真实性能和竞争力的难度,可能导致市场预期偏差。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">商业化风险</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 text-sm sm:text-base">
|
||||
<li><strong class="text-red-400">对华销售成本飙升与性价比缺失:</strong> 美国政府对H200芯片对华销售收取25%销售额的费用,将大幅增加中国客户的采购成本,降低其在中国的性价比。新闻指出“其附带销售分成推高采购成本,市场渗透仍然存在挑战”,且“H200在国内推理模型的推理任务中,跟国产卡比,不具备性价比优势”。高成本会直接影响其市场竞争力。</li>
|
||||
<li><strong class="text-red-400">市场接受度不确定性:</strong> 英伟达CEO黄仁勋明确表示“不确定中国是否会接受该公司的H200人工智能芯片”,以及“中方正在考虑限制获取英伟达的H200芯片”,这意味着即使美国放开出口,中国市场也可能出于战略安全、成本或支持国产等原因,不进行大规模采购,导致商业化受阻。</li>
|
||||
<li><strong class="text-red-400">H200可能已停产的传闻:</strong> 新闻中“西部计算机”分析指出H200在25年6月已停产,若此消息属实,将严重限制其供应量和市场渗透,使得其作为主力产品的逻辑被证伪,商业化前景黯淡。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-red-300">政策与竞争风险</h4>
|
||||
<ul class="list-disc pl-5 text-gray-300 text-sm sm:text-base">
|
||||
<li><strong class="text-red-400">政策变化风险:</strong> 美国对华AI芯片出口政策仍存在不确定性和反复的可能性。当前的“解禁”只是有条件放开,未来仍可能因地缘政治等因素再次收紧,直接影响H200的对华销售。</li>
|
||||
<li><strong class="text-red-400">激烈的国内竞争:</strong> H200入华将加剧国内高端芯片市场竞争。<strong class="text-red-400">华为昇腾910B</strong>在部分场景性能上已能对标H200并更具价格优势。海光信息等国产厂商也在积极提升产品性能,并强调安全合规与性价比。这种激烈的竞争将限制H200的市场份额和盈利空间。</li>
|
||||
<li><strong class="text-red-400">国产替代加速:</strong> 中方对H200的态度以及对其“安全隐患”(如网信办通报H20芯片存在“追踪定位、远程关闭”后门风险)的担忧,将加速国内对AI芯片的自主可控进程,进一步推动国产替代,减少对英伟达芯片的依赖。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3 text-red-300">信息交叉验证风险</h3>
|
||||
<p class="text-gray-300 text-sm sm:text-base">
|
||||
关于H200的发布时间、量产时间、产品生命周期(“25年6月已停产”)、以及多项核心性能参数(HBM容量、带宽、TDP、算力)在不同信息源之间存在<strong class="text-red-400">显著且无法调和的矛盾</strong>。这使得对H200的准确判断变得困难,市场在接收信息时需高度警惕其准确性和时效性。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Section 6: 综合结论与投资启示 -->
|
||||
<section class="glass-card p-8 shadow-xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-fuchsia-300">6. 综合结论与投资启示</h2>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">综合结论</h3>
|
||||
<p class="text-gray-300 leading-relaxed mb-6 text-sm sm:text-base">
|
||||
英伟达H200概念目前处于一个<strong class="text-red-300">复杂的过渡性阶段</strong>。它既是全球AI算力增长的必然产物,承载着先进技术迭代的价值,又在特定市场(尤其是中国)面临政策、成本和竞争等多重不确定性。因此,该概念更倾向于处于<strong class="text-red-300">由“技术驱动的主题炒作”向“基本面驱动的复杂博弈”演进的阶段</strong>。市场对H200的政策利好反应迅速,但其在中国市场的实际落地和长期影响远未明朗。
|
||||
</p>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">最具投资价值的细分环节或方向</h3>
|
||||
<div class="bento-grid mb-6">
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">AI数据中心基础设施(液冷、高速互联、高阶电源)</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">无论H200市场表现如何,全球AI算力需求爆发和芯片功耗提升是确定性趋势。液冷、高速互联、高功率电源是刚性需求,且技术壁垒高,受益于整个AI算力产业的结构性升级,而非单一芯片的成败。</p>
|
||||
<p class="text-fuchsia-300 mt-2 text-sm sm:text-base">代表公司:中恒电气、英维克、欧陆通、立讯精密、领益智造、中际旭创、博创科技、胜宏科技、生益科技、沪电股份。</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">国产AI算力生态核心配套商</h4>
|
||||
<p class="text-gray-300 text-sm sm:text-base">考虑到H200入华面临的成本和政策挑战,以及国家对自主可控的战略需求,国产AI芯片(如华为昇腾、海光信息)的崛起是长期趋势。为这些国产芯片提供核心配套的供应商,将受益于国产替代的加速。</p>
|
||||
<p class="text-fuchsia-300 mt-2 text-sm sm:text-base">代表公司:川润股份。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold mb-4 text-fuchsia-300">需要重点跟踪和验证的关键指标</h3>
|
||||
<ul class="list-disc pl-5 text-gray-300 mb-6 space-y-2 text-sm sm:text-base">
|
||||
<li><strong class="text-blue-300">H200在华销售的最终政策细节与采购订单:</strong> 跟踪中美双方就H200贸易的最终协议,以及国内主要云服务商和大型企业对H200的实际采购量、采购价格(包含25%分成后的TCO)和交付周期。这是验证其在中国市场接受度的<strong class="text-blue-300">最直接指标</strong>。</li>
|
||||
<li><strong class="text-blue-300">NVIDIA H200的实际生产状态与供应能力:</strong> 澄清“H200在25年6月已停产”的传闻,关注英伟达官方对H200产能规划和供应状况的最新说明。</li>
|
||||
<li><strong class="text-blue-300">国内AIDC资本开支的变化与结构:</strong> 跟踪国内BAT等大型云服务商的资本开支计划,特别是投向AI服务器和算力基础设施的比例,以及其采购芯片的结构(H200与国产芯片的占比)。</li>
|
||||
<li><strong class="text-blue-300">国产AI芯片的性能提升与市场份额变化:</strong> 关注华为昇腾、海光信息等国产AI芯片的最新产品进展、性能评测、生态建设情况及其在数据中心市场的渗透率。</li>
|
||||
<li><strong class="text-blue-300">液冷技术在AI数据中心的渗透率与相关厂商的订单增长:</strong> 跟踪AI服务器液冷方案的实际落地情况,以及相关液冷设备(如CDU、液冷板、快接头)供应商的订单量和收入增速。</li>
|
||||
<li><strong class="text-blue-300">英伟达下一代产品(Blackwell、Rubin)的发布进度与性能:</strong> 关注NVIDIA下一代产品的进展,这会影响H200的相对竞争力与产品生命周期。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Section: 关联股票列表 -->
|
||||
<section class="glass-card p-8 shadow-xl">
|
||||
<h2 class="text-3xl font-bold mb-6 text-fuchsia-300">关联股票列表</h2>
|
||||
<p class="text-gray-300 mb-4 text-sm sm:text-base">以下是与“英伟达H200”概念相关的股票,按产业链环节划分。</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full text-gray-200 text-sm sm:text-base">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-fuchsia-300">股票名称</th>
|
||||
<th class="text-fuchsia-300">股票代码</th>
|
||||
<th class="text-fuchsia-300">关联原因</th>
|
||||
<th class="text-fuchsia-300">其他标签</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Stock data will be inserted here by JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold mt-8 mb-4 text-fuchsia-300">个股涨幅分析(精选案例)</h3>
|
||||
<p class="text-gray-300 mb-4 text-sm sm:text-base">以下是对部分有显著涨幅表现的关联个股进行的异动分析。</p>
|
||||
<div x-data="{ selectedStock: '鸿日达' }">
|
||||
<div class="tabs tabs-boxed mb-6 bg-base-200 rounded-xl p-2 flex flex-wrap justify-center">
|
||||
<template x-for="stock in ['鸿日达', '科华数据', '中恒电气', '杰美特', '英维克', '川润股份', '隆扬电子', '依米康']" :key="stock">
|
||||
<a @click="selectedStock = stock" :class="{'tab-active': selectedStock === stock}" class="tab text-white btn-ghost text-sm sm:text-base" x-text="stock"></a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedStock === '鸿日达'" class="glass-card p-6 mb-4">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">鸿日达(301285) 2025年12月10日 (+13.32%) 股价异动分析</h4>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
<strong>摘要:</strong> 鸿日达(301285)于2025年12月10日大幅上涨13.32%,其核心驱动因素源于市场对英伟达H200芯片可能对华解禁的预期,以及由此引发的AI服务器产业链投资热潮。尽管公司未发布公告明确其业务关联,但市场舆情,特别是投资者论坛的讨论,将鸿日达定位为AI服务器散热解决方案(尤其是液冷板)的潜在核心供应商。这一逻辑链条结合该股前期的高融资余额和浓厚的投机氛围,共同促成了本次股价的显著拉升。
|
||||
<br/><br/>
|
||||
**核心驱动因素:英伟达H芯片解禁预期与产业链逻辑外溢:** 最直接、最强劲的催化因素是围绕英伟达高端AI芯片对华销售政策的潜在转变。市场解读为美国对华高端AI芯片限制政策的重大转向信号。H200若能进入中国市场,将极大刺激国内AI数据中心(AIDC)建设需求。随着AI芯片功耗攀升,散热和电源成为关键环节,液冷方案从“可选”变为“必选”。投资者论坛将鸿日达与“液冷板”强绑定,为其提供了巨大的想象空间和“预期差”,吸引了投机资金。
|
||||
<br/><br/>
|
||||
**市场情绪与资金面分析:** 股价剧烈波动离不开市场情绪和资金面的配合,鸿日达本次上涨是典型的事件驱动与资金推动型行情。投资者论坛弥漫着短线投机情绪,“妖股”论调盛行。融资余额创历史新高(4.75亿元),表明杠杆资金积极做多,放大了购买力并加剧了股价的单边上扬。高杠杆状态也意味着高波动性和回调风险。
|
||||
<br/><br/>
|
||||
**风险提示:** 最大的风险在于公司的实际业务与市场预期严重不符。其“主营: 暂无”意味着液冷板逻辑均为市场推测。一旦公司澄清,股价可能急剧回调。高估值与高杠杆风险显著。英伟达H20/H200的对华政策不确定性,可能动摇本轮行情核心逻辑。
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="selectedStock === '科华数据'" class="glass-card p-6 mb-4">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">科华数据(002335) 2025年07月15日 (+6.95%) 股价异动分析</h4>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
科华数据在2025-07-15出现显著上涨,核心原因是算力租赁概念板块的整体走强,而这一走势的直接催化剂是英伟达CEO黄仁勋宣布美国已批准H20芯片销往中国的重大消息。H20芯片的批准意味着中国AI企业将获得更强大的算力支持,直接利好整个算力产业链。科华数据作为国内领先的IT基础设施服务商,在数据中心、IDC和UPS等领域有深厚积累,其业务与算力基础设施高度相关,直接受益于算力需求的增长。同时,公司在SST等技术领域的储备也为其在算力基础设施领域的竞争提供了有力支撑。
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="selectedStock === '中恒电气'" class="glass-card p-6 mb-4">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">中恒电气(002364) 2025年09月05日 (+10.0%) 股价异动分析</h4>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
中恒电气于9月5日公告HVDC电源通过英伟达GB200 NVL72机柜认证并可批量供货,叠加英伟达上调2025Q4机柜出货指引50%,机构单日净买入4286万元,资金与基本面共振封板。公司是阿里HVDC核心供应商,拥有10年以上大型数据中心批量供货经验。GB200 NVL72机柜对HVDC电源是刚需,高功率HVDC模块毛利率优于传统方案。公司作为国内龙头,技术壁垒高,新玩家切入需2-3年。
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="selectedStock === '杰美特'" class="glass-card p-6 mb-4">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">杰美特(300868) 2025年07月15日 (+5.9%) 股价异动分析</h4>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
杰美特当日上涨的主要原因是英伟达官宣恢复H20在中国销售的重大利好消息。杰美特通过今年6月并购思腾合力,成功切入英伟达产业链,思腾合力是英伟达精英级合作伙伴,并获得H100、H800以及H20代理权。这一战略布局使杰美特成为英伟达H20恢复在中国销售的直接受益者,市场预期其将获得可观的分销收入。市场之前可能未充分认识到杰美特与英伟达合作的价值,这种认知偏差为敏锐的投资者提供了低位布局的机会,推动了股价上涨。
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="selectedStock === '英维克'" class="glass-card p-6 mb-4">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">英维克(002837) 2025年08月28日 (+10.01%) 股价异动分析</h4>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
英伟达财报确认Blackwell GPU放量+供应链验证英维克为液冷CDU中国独家核心供应商,盈利预期一次性上台阶,资金抢筹封板。Blackwell GPU 2025Q3批量交付,单卡功耗≥1000W,必须液冷;英维克获中国区CDU+快接头70%份额。全球AI算力中心未来几年装机量将大幅增长,液冷渗透率提升。公司2025年液冷收入目标20亿元,同比翻倍。
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="selectedStock === '川润股份'" class="glass-card p-6 mb-4">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">川润股份(002272) 2025年07月31日 (+9.98%) 股价异动分析</h4>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
网信办通报英伟达H20芯片安全隐患→国产算力替代预期升温→川润股份作为昇腾AI服务器液冷核心配套商被资金抢筹。公司向华为Atlas 900昇腾AI服务器批量供应液冷板、快接头、油泵,2025H1相关收入占比已升至28%。全球液冷龙头维谛技术业绩超预期,强化液冷渗透率提升逻辑。公司液冷板、快换接头等产品已配套华为、阿里、腾讯等数据中心。
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="selectedStock === '隆扬电子'" class="glass-card p-6 mb-4">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">隆扬电子(301389) 2025年08月27日 (+20.0%) 股价异动分析</h4>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
英伟达GB300/Rubin架构对HVLP5极低轮廓铜箔的刚性需求+威斯双联并购落地+淮安工厂招工放量,三因素共振触发资金抢跑20cm涨停。224G/448G信号速率必须用HVLP5铜箔降插损;SwitchTray/ComputeTray子卡已锁定HVLP5,隆扬电子是国内唯一能量产且已进入二级供应链的铜箔厂,验证基本通过,9月将开始拉货。并购威斯双联后,公司升级为“铜箔+表面处理+树脂体系”整体方案供应商。
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="selectedStock === '依米康'" class="glass-card p-6 mb-4">
|
||||
<h4 class="text-xl font-semibold mb-3 text-blue-300">依米康(300249) 2025年07月15日 (+8.68%) 股价异动分析</h4>
|
||||
<p class="text-gray-300 leading-relaxed text-sm sm:text-base">
|
||||
依米康当日上涨主要受到液冷服务器和算力租赁概念整体走强的推动,而英伟达H20芯片获准销往中国的重大利好消息成为直接催化剂。这一政策变动提振了整个AI产业链的信心,市场预期依米康作为相关概念股将受益于行业景气度提升。同时,技术面突破和积极的市场情绪也助推了股价上涨。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Echarts for Performance Comparison
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var chartDom = document.getElementById('performance-chart');
|
||||
if (chartDom) {
|
||||
var myChart = echarts.init(chartDom, 'dark'); // Using a 'dark' theme for Echarts
|
||||
var option = {
|
||||
title: {
|
||||
text: 'H200/H100/H20 关键性能指标对比',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: '#f0e0ff',
|
||||
fontSize: 18
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' }
|
||||
},
|
||||
legend: {
|
||||
data: ['H200', 'H100', 'H20'],
|
||||
textStyle: {
|
||||
color: '#e0e0e0'
|
||||
},
|
||||
top: 'bottom'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '12%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['HBM容量 (GB)', '带宽 (TB/s)', 'FP16算力 (PFLOPS)', 'FP8算力 (PFLOPS)', '推理速度 (对比H100)'],
|
||||
axisLabel: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 10,
|
||||
interval: 0, // Force all labels to show
|
||||
rotate: 30 // Rotate labels for better readability
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 10
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'H200',
|
||||
type: 'bar',
|
||||
data: [
|
||||
{ value: 141, itemStyle: { color: '#8884d8' } }, // HBM容量 (新闻)
|
||||
{ value: 4.8, itemStyle: { color: '#8884d8' } }, // 带宽 (研报)
|
||||
{ value: 5, itemStyle: { color: '#8884d8' } }, // FP16 (新闻/研报)
|
||||
{ value: 10, itemStyle: { color: '#8884d8' } }, // FP8 (研报)
|
||||
{ value: 2, itemStyle: { color: '#8884d8' } } // 推理速度 (路演,H100基准为1,H200提升1倍即2)
|
||||
],
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: '#e0e0e0',
|
||||
fontSize: 10
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: [5, 5, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'H100',
|
||||
type: 'bar',
|
||||
data: [
|
||||
{ value: 80, itemStyle: { color: '#82ca9d' } }, // HBM容量 (新闻)
|
||||
{ value: 3.35, itemStyle: { color: '#82ca9d' } }, // 带宽 (研报)
|
||||
{ value: 5, itemStyle: { color: '#82ca9d' } }, // FP16 (研报)
|
||||
{ value: 4, itemStyle: { color: '#82ca9d' } }, // FP8 (研报)
|
||||
{ value: 1, itemStyle: { color: '#82ca9d' } } // 基准
|
||||
],
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: '#e0e0e0',
|
||||
fontSize: 10
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: [5, 5, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'H20',
|
||||
type: 'bar',
|
||||
data: [
|
||||
{ value: 96, itemStyle: { color: '#ffc658' } }, // HBM容量 (新闻)
|
||||
{ value: null, itemStyle: { color: '#ffc658' } }, // 数据缺失或不适用
|
||||
{ value: (5 / 13.4), itemStyle: { color: '#ffc658' } }, // FP16 (新闻: H200是H20的13.4倍)
|
||||
{ value: null, itemStyle: { color: '#ffc658' } }, // 数据缺失或不适用
|
||||
{ value: (2 / 10), itemStyle: { color: '#ffc658' } } // 推理速度 (新闻: H200是H20的10倍)
|
||||
],
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: function(params) { // Custom formatter to handle null and round numbers
|
||||
if (params.value === null) return '';
|
||||
return params.value.toFixed(2);
|
||||
},
|
||||
color: '#e0e0e0',
|
||||
fontSize: 10
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: [5, 5, 0, 0]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
myChart.setOption(option);
|
||||
|
||||
// Resize chart with window
|
||||
window.addEventListener('resize', function() {
|
||||
myChart.resize();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Stock data for table
|
||||
const stockData = {
|
||||
"英伟达H200(251209)": {
|
||||
"8AA7A905-6B9E-4F11-B3E4-75295E8880FF.png": [
|
||||
{ "stock": "弘信电子", "reason": "代理", "其他标签": "英伟达产业链", "stock_code": "300657" },
|
||||
{ "stock": "神州数码", "reason": "代理", "其他标签": "英伟达产业链", "stock_code": "000034" },
|
||||
{ "stock": "紫光股份", "reason": "代理", "其他标签": "英伟达产业链", "stock_code": "000938" },
|
||||
{ "stock": "中电港", "reason": "代理", "其他标签": "英伟达产业链", "stock_code": "001287" },
|
||||
{ "stock": "三人行", "reason": "代理", "其他标签": "英伟达产业链", "stock_code": "605168" },
|
||||
{ "stock": "工业富联", "reason": "组装", "其他标签": "英伟达产业链", "stock_code": "601138" },
|
||||
{ "stock": "华勤技术", "reason": "组装", "其他标签": "英伟达产业链", "stock_code": "603296" },
|
||||
{ "stock": "工业富联", "reason": "服务器", "其他标签": "英伟达产业链", "stock_code": "601138" },
|
||||
{ "stock": "华勤技术", "reason": "服务器", "其他标签": "英伟达产业链", "stock_code": "603296" },
|
||||
{ "stock": "浪潮信息", "reason": "服务器", "其他标签": "英伟达产业链", "stock_code": "000977" },
|
||||
{ "stock": "航锦科技", "reason": "服务器", "其他标签": "英伟达产业链", "stock_code": "000818" },
|
||||
{ "stock": "紫光股份", "reason": "服务器", "其他标签": "英伟达产业链", "stock_code": "000938" },
|
||||
{ "stock": "博创科技", "reason": "光模块", "其他标签": "英伟达产业链", "stock_code": "300548" }, /* Added stock_code */
|
||||
{ "stock": "致尚科技", "reason": "光模块", "其他标签": "英伟达产业链", "stock_code": "301486" },
|
||||
{ "stock": "光库科技", "reason": "光模块", "其他标签": "英伟达产业链", "stock_code": "300620" },
|
||||
{ "stock": "中际旭创", "reason": "光模块", "其他标签": "英伟达产业链", "stock_code": "300308" },
|
||||
{ "stock": "太辰光", "reason": "光模块", "其他标签": "英伟达产业链", "stock_code": "300570" },
|
||||
{ "stock": "鸿博股份", "reason": "算力中心", "其他标签": "英伟达产业链", "stock_code": "002229" },
|
||||
{ "stock": "润建股份", "reason": "算力中心", "其他标签": "英伟达产业链", "stock_code": "002929" },
|
||||
{ "stock": "立讯精密", "reason": "高速互联", "其他标签": "英伟达产业链", "stock_code": "002475" },
|
||||
{ "stock": "汇聚科技", "reason": "高速互联", "其他标签": "英伟达产业链", "stock_code": "688553" }, /* Added stock_code */
|
||||
{ "stock": "东山精密", "reason": "高速互联", "其他标签": "英伟达产业链", "stock_code": "002384" },
|
||||
{ "stock": "致尚科技", "reason": "高速互联", "其他标签": "英伟达产业链", "stock_code": "301486" },
|
||||
{ "stock": "淳中科技", "reason": "液冷/电源", "其他标签": "英伟达产业链", "stock_code": "603516" },
|
||||
{ "stock": "立讯精密", "reason": "液冷/电源", "其他标签": "英伟达产业链", "stock_code": "002475" },
|
||||
{ "stock": "领益智造", "reason": "液冷/电源", "其他标签": "英伟达产业链", "stock_code": "002600" },
|
||||
{ "stock": "奥海科技", "reason": "液冷/电源", "其他标签": "英伟达产业链", "stock_code": "002993" },
|
||||
{ "stock": "奕东电子", "reason": "液冷/电源", "其他标签": "英伟达产业链", "stock_code": "301123" },
|
||||
{ "stock": "比亚迪电子", "reason": "液冷/电源", "其他标签": "英伟达产业链", "stock_code": "0285.HK" }, /* Added stock_code, assuming HKEX format for now, or specify A-share if applicable */
|
||||
{ "stock": "欧陆通", "reason": "液冷/电源", "其他标签": "英伟达产业链", "stock_code": "300870" },
|
||||
{ "stock": "东阳光", "reason": "液冷/电源", "其他标签": "英伟达产业链", "stock_code": "600673" },
|
||||
{ "stock": "胜宏科技", "reason": "PCB/CCL", "其他标签": "英伟达产业链", "stock_code": "300476" },
|
||||
{ "stock": "生益科技", "reason": "PCB/CCL", "其他标签": "英伟达产业链", "stock_code": "600183" },
|
||||
{ "stock": "景旺电子", "reason": "PCB/CCL", "其他标签": "英伟达产业链", "stock_code": "603228" },
|
||||
{ "stock": "沪电股份", "reason": "PCB/CCL", "其他标签": "英伟达产业链", "stock_code": "002463" },
|
||||
{ "stock": "方正科技", "reason": "PCB/CCL", "其他标签": "英伟达产业链", "stock_code": "600601" },
|
||||
{ "stock": "鹏鼎控股", "reason": "PCB/CCL", "其他标签": "英伟达产业链", "stock_code": "002938" },
|
||||
{ "stock": "超颖电子", "reason": "PCB/CCL", "其他标签": "英伟达产业链", "stock_code": "603175" },
|
||||
{ "stock": "东山精密", "reason": "PCB/CCL", "其他标签": "英伟达产业链", "stock_code": "002384" },
|
||||
{ "stock": "大族数控", "reason": "PCB设备", "其他标签": "英伟达产业链", "stock_code": "301200" },
|
||||
{ "stock": "大族激光", "reason": "PCB设备", "其他标签": "英伟达产业链", "stock_code": "002008" },
|
||||
{ "stock": "芯碁微装", "reason": "PCB设备", "其他标签": "英伟达产业链", "stock_code": "688630" },
|
||||
{ "stock": "鼎泰高科", "reason": "PCB设备", "其他标签": "英伟达产业链", "stock_code": "301377" },
|
||||
{ "stock": "大位科技", "reason": "IDC", "其他标签": "国产AIDC", "stock_code": "600589" },
|
||||
{ "stock": "润泽科技", "reason": "IDC", "其他标签": "国产AIDC", "stock_code": "300442" },
|
||||
{ "stock": "奥飞数据", "reason": "IDC", "其他标签": "国产AIDC", "stock_code": "300738" },
|
||||
{ "stock": "光环新网", "reason": "IDC", "其他标签": "国产AIDC", "stock_code": "300383" },
|
||||
{ "stock": "杭钢股份", "reason": "IDC", "其他标签": "国产AIDC", "stock_code": "600126" },
|
||||
{ "stock": "数据港", "reason": "IDC", "其他标签": "国产AIDC", "stock_code": "603881" },
|
||||
{ "stock": "潍柴重机", "reason": "IDC配套", "其他标签": "国产AIDC", "stock_code": "000880" },
|
||||
{ "stock": "光迅科技", "reason": "IDC配套", "其他标签": "国产AIDC", "stock_code": "002281" },
|
||||
{ "stock": "华工科技", "reason": "IDC配套", "其他标签": "国产AIDC", "stock_code": "000988" },
|
||||
{ "stock": "锐捷网络", "reason": "IDC配套", "其他标签": "国产AIDC", "stock_code": "301165" },
|
||||
{ "stock": "宏景科技", "reason": "算力租赁", "其他标签": "国产AIDC", "stock_code": "301396" },
|
||||
{ "stock": "协创数据", "reason": "算力租赁", "其他标签": "国产AIDC", "stock_code": "300857" },
|
||||
{ "stock": "科士达", "reason": "UPS&HVDC环节", "其他标签": "英伟达产业链", "stock_code": "002518" },
|
||||
{ "stock": "科华数据", "reason": "UPS&HVDC环节", "其他标签": "英伟达产业链", "stock_code": "002335" },
|
||||
{ "stock": "中恒电气", "reason": "UPS&HVDC环节", "其他标签": "英伟达产业链", "stock_code": "002364" },
|
||||
{ "stock": "金盘科技", "reason": "变压器环节", "其他标签": "英伟达产业链", "stock_code": "688676" },
|
||||
{ "stock": "伊戈尔", "reason": "变压器环节", "其他标签": "英伟达产业链", "stock_code": "002922" },
|
||||
{ "stock": "明阳电气", "reason": "变压器环节", "其他标签": "英伟达产业链", "stock_code": "301291" },
|
||||
{ "stock": "南都电源", "reason": "铅酸&锂电备电环节", "其他标签": "英伟达产业链", "stock_code": "300068" },
|
||||
{ "stock": "双登股份", "reason": "铅酸&锂电备电环节", "其他标签": "英伟达产业链", "stock_code": "001213" },
|
||||
{ "stock": "雄韬股份", "reason": "铅酸&锂电备电环节", "其他标签": "英伟达产业链", "stock_code": "002733" },
|
||||
{ "stock": "锐捷网络", "reason": "数据中心交换机", "其他标签": "英伟达产业链", "stock_code": "301165" },
|
||||
{ "stock": "星网锐捷", "reason": "数据中心交换机", "其他标签": "英伟达产业链", "stock_code": "002396" },
|
||||
{ "stock": "紫光股份", "reason": "数据中心交换机", "其他标签": "英伟达产业链", "stock_code": "000938" },
|
||||
{ "stock": "菲菱科思", "reason": "数据中心交换机", "其他标签": "英伟达产业链", "stock_code": "301191" },
|
||||
{ "stock": "盛科通信", "reason": "数据中心交换机", "其他标签": "英伟达产业链", "stock_code": "688702" },
|
||||
{ "stock": "共进股份", "reason": "数据中心交换机", "其他标签": "英伟达产业链", "stock_code": "603118" },
|
||||
{ "stock": "中兴通讯", "reason": "其他(通信/芯片设计等)", "其他标签": "英伟达产业链", "stock_code": "000063" },
|
||||
{ "stock": "烽火通信", "reason": "其他(通信/芯片设计等)", "其他标签": "英伟达产业链", "stock_code": "600498" },
|
||||
{ "stock": "翱捷科技", "reason": "其他(通信/芯片设计等)", "其他标签": "英伟达产业链", "stock_code": "688220" },
|
||||
{ "stock": "灿芯股份", "reason": "其他(通信/芯片设计等)", "其他标签": "英伟达产业链", "stock_code": "688691" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const tbody = document.querySelector('table tbody');
|
||||
for (const conceptKey in stockData) {
|
||||
const stocks = stockData[conceptKey]['8AA7A905-6B9E-4F11-B3E4-75295E8880FF.png']; // Assuming this is the main list
|
||||
stocks.forEach(item => {
|
||||
const row = tbody.insertRow();
|
||||
row.classList.add('hover:bg-base-300', 'transition', 'duration-150', 'ease-in-out');
|
||||
const cell1 = row.insertCell();
|
||||
cell1.textContent = item.stock;
|
||||
const cell2 = row.insertCell();
|
||||
if (item.stock_code) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `https://valuefrontier.cn/company?scode=${item.stock_code.replace('.HK', '')}`; // Remove .HK for A-share lookup
|
||||
link.textContent = item.stock_code;
|
||||
link.target = '_blank';
|
||||
link.classList.add('text-blue-300', 'hover:underline');
|
||||
cell2.appendChild(link);
|
||||
} else {
|
||||
cell2.textContent = 'N/A';
|
||||
}
|
||||
const cell3 = row.insertCell();
|
||||
cell3.textContent = item.reason;
|
||||
const cell4 = row.insertCell();
|
||||
cell4.textContent = item['其他标签'];
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
986
public/htmls/零售百货胖东来.html
Normal file
@@ -0,0 +1,986 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>零售百货胖东来 概念深度行研报告</title>
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- DaisyUI CDN -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.css" rel="stylesheet" type="text/css" />
|
||||
<!-- Alpine.js CDN -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<!-- ECharts CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Noto+Sans+SC:wght@300;400;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
background: radial-gradient(circle at top left, #1a2a6c, #0f1c3a, #0b1a2e); /* Deep space gradient */
|
||||
background-size: 200% 200%;
|
||||
animation: gradientAnimation 15s ease infinite;
|
||||
color: #E0E7FF; /* Light blue-ish white for text */
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
color: #C0DEFF; /* Slightly brighter blue for headings */
|
||||
text-shadow: 0 0 8px rgba(192, 222, 255, 0.4);
|
||||
}
|
||||
|
||||
@keyframes gradientAnimation {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(18, 30, 52, 0.4); /* Semi-transparent dark blue */
|
||||
backdrop-filter: blur(10px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(180%);
|
||||
border: 1px solid rgba(192, 222, 255, 0.2);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
border-radius: 2rem; /* Extreme rounded corners */
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
.glass-card:hover {
|
||||
background: rgba(18, 30, 52, 0.55); /* Slightly less transparent on hover */
|
||||
border-color: rgba(192, 222, 255, 0.4);
|
||||
box-shadow: 0 12px 48px 0 rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.text-neon {
|
||||
color: #A7F3D0; /* A greenish neon color */
|
||||
text-shadow: 0 0 5px #A7F3D0, 0 0 10px #A7F3D0, 0 0 15px rgba(167, 243, 208, 0.5);
|
||||
}
|
||||
|
||||
.code-link {
|
||||
color: #8be9fd; /* light blue for code links */
|
||||
text-decoration: none;
|
||||
}
|
||||
.code-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Specific styling for list items inside glass cards */
|
||||
.glass-card ul li {
|
||||
position: relative;
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.glass-card ul li::before {
|
||||
content: '›'; /* FUI-style bullet point */
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #6EE7B7; /* Light green accent */
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Styling for the table inside glass card */
|
||||
.table-glass th, .table-glass td {
|
||||
background-color: transparent !important;
|
||||
border-color: rgba(192, 222, 255, 0.1) !important;
|
||||
color: #E0E7FF;
|
||||
}
|
||||
.table-glass th {
|
||||
color: #C0DEFF;
|
||||
text-shadow: 0 0 5px rgba(192, 222, 255, 0.2);
|
||||
}
|
||||
.table-glass tr:hover {
|
||||
background-color: rgba(192, 222, 255, 0.05) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="p-8">
|
||||
<div class="container mx-auto p-6 lg:p-12 glass-card max-w-7xl">
|
||||
<header class="text-center mb-10">
|
||||
<h1 class="text-5xl font-bold mb-4">零售百货胖东来 概念深度行研报告</h1>
|
||||
<p class="text-xl text-neon">北京价值前沿科技有限公司 AI投研agent:“价小前投研” 进行投研呈现,本报告为AI合成数据,投资需谨慎。</p>
|
||||
</header>
|
||||
|
||||
<!-- Section: 0. 概念事件 -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">0. 概念事件</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">背景</h3>
|
||||
<p class="text-gray-300 leading-relaxed">
|
||||
胖东来,全称许昌市胖东来商贸集团有限公司,成立于1997年9月(新闻数据),创始人于东来。自1995年以烟酒店起步(研报数据),胖东来逐渐发展成为河南区域性零售巨头,其业务涵盖超市、百货、电器、医药、餐饮、珠宝、茶叶等多个领域。其独特的“自由、爱”企业文化、对员工的极致关怀(高薪酬、高福利、长假期)、对顾客的极致服务(无理由退货、细致入微的增值服务)、以及高效精简的供应链和品质商品策略,使其在零售行业中独树一帜,并获得了消费者的高度认可和忠诚。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6 md:col-span-2">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">催化事件</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**模式输出与行业赋能:** 自2024年4月起,胖东来开始对面临经营困境的传统零售企业(如步步高、永辉超市等)进行“帮扶式调改”,这成为引爆该概念的核心催化剂。调改内容涉及商品结构优化、价格调整、员工薪酬提升、服务标准升级和门店环境改造等。
|
||||
</li>
|
||||
<li>
|
||||
**调改效果显著验证:**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**步步高长沙梅溪湖店:** 经调改后,日均销售额从调改前的<strong class="text-neon">15万元</strong>飙升至<strong class="text-neon">130万元</strong>(增长近9倍),日均客流从<strong class="text-neon">0.2万人次</strong>增至<strong class="text-neon">1.2万人次</strong>(路演数据)。
|
||||
</li>
|
||||
<li>
|
||||
**永辉超市:** 调改门店如郑州瀚海海尚店恢复营业后,日销从<strong class="text-neon">15-16万元</strong>增至<strong class="text-neon">200万元(峰值)</strong>,客单价提升4倍,客流达<strong class="text-neon">1万人次/日</strong>(路演数据)。截至2025年6月30日,永辉已完成<strong class="text-neon">124家</strong>门店的调改。
|
||||
</li>
|
||||
<li>
|
||||
**新华百货吴忠万达店:** 调改后日销从<strong class="text-neon">13万元</strong>提升至<strong class="text-neon">百万级</strong>,验证了胖东来模式在低能级城市可复制性(路演数据)。
|
||||
</li>
|
||||
</ul>
|
||||
这些成功案例引发了其他零售企业(如中百集团、汇嘉时代、重庆百货等)的广泛学习和合作意向。
|
||||
</li>
|
||||
<li>
|
||||
**自身业绩爆发式增长:** 胖东来集团自身销售额从2023年的<strong class="text-neon">约100亿元</strong>(研报数据,部分路演数据为46亿元,存在矛盾)跃升至2024年的<strong class="text-neon">169.64亿元</strong>(新闻数据),同比增长<strong class="text-neon">58.54%</strong>。2024年利润达<strong class="text-neon">8亿多元(净利率约5%)</strong>。预计2025年集团总销售额有望突破<strong class="text-neon">200亿元</strong>,净利润达<strong class="text-neon">15亿元</strong>(新闻数据)。
|
||||
</li>
|
||||
<li>
|
||||
**积极扩张与透明化:** 胖东来宣布将进军河南省会郑州,计划开设一个具有艺术特色的超市作品,并投资<strong class="text-neon">50亿元</strong>建设许昌“梦之城”、郑州高铁东站超市等大型项目(新闻数据)。此外,2025年3月,胖东来在官网全面公开销售数据与管理制度,提升了透明度和市场讨论热度。
|
||||
</li>
|
||||
<li>
|
||||
**关联公司股价异动:** 胖东来强大的渠道能力甚至引发了上游供应商的股价异动,例如<strong class="text-neon">酒鬼酒 (000799.SZ)</strong> 在2025年11月10日因“胖东来系统已大批量进货酒鬼酒”的消息而涨停,市场预期新增渠道可贡献其营收的<strong class="text-neon">50%以上</strong>。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6 md:col-span-3">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">时间轴</h3>
|
||||
<ul class="list-none pl-0 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<li><strong class="text-neon">1995年:</strong> 胖东来成立。</li>
|
||||
<li><strong class="text-neon">2024年4月:</strong> 胖东来启动对步步高超市的帮扶调改。</li>
|
||||
<li><strong class="text-neon">2024年8月7日:</strong> 胖东来调改的永辉超市郑州瀚海海尚店恢复营业。</li>
|
||||
<li><strong class="text-neon">2024年全年:</strong> 胖东来集团累计销售额达<strong class="text-neon">169.64亿元</strong>。</li>
|
||||
<li><strong class="text-neon">2025年3月:</strong> 胖东来在官网全面公开销售数据与管理制度。</li>
|
||||
<li><strong class="text-neon">2025年4月:</strong> 胖东来宣布进军郑州,首店预计2026年元旦前亮相。</li>
|
||||
<li><strong class="text-neon">2025年6月13日:</strong> 永辉超市完成第<strong class="text-neon">100家</strong>胖东来模式调改门店。</li>
|
||||
<li><strong class="text-neon">2025年11月10日:</strong> 酒鬼酒因“胖东来渠道”传闻涨停。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section: 1. 核心观点摘要 -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">1. 核心观点摘要</h2>
|
||||
<p class="text-gray-300 leading-relaxed">
|
||||
零售百货胖东来概念已从区域现象级成功案例,跃升为传统零售业转型的核心参照系,其独特的“以人为本”文化和极致运营模式,正在通过“帮扶式调改”引发行业深层次变革。尽管胖东来自身坚持区域深耕且不上市,但其模式输出和供应链共享,为深陷增长困境的传统零售企业提供了清晰的盈利路径和体验升级方向,驱动相关上市公司表现。当前概念处于高速发展和模式验证阶段,未来增长潜力主要来源于胖东来自身扩张和帮扶范围的扩大,但需警惕模式复制的深度和广度、以及市场情绪过热后的潜在风险。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Section: 2. 概念的核心逻辑与市场认知分析 -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">2. 概念的核心逻辑与市场认知分析</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">核心驱动力</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**“自由、爱”的利他主义文化:** 这是胖东来一切成功的基石,其核心在于实现企业与员工、顾客和供应商之间的<strong class="text-neon">共赢</strong>。对员工,胖东来提供远超行业水平的薪酬福利(2024年员工平均月收入<strong class="text-neon">9千多元</strong>,2025年1-2月达<strong class="text-neon">9886元</strong>), generous假期(一年一个月带薪年假,未来工作时间不超过<strong class="text-neon">36小时/周</strong>,年休假不低于<strong class="text-neon">40天</strong>,并有“不开心假期”),以及利润共享机制(95%利润分配给员工,50%股权给基层员工)。这极大地激发了员工的归属感和工作积极性。对顾客,则以无底线的极致服务(500元投诉奖励、无理由退换货、免费增值服务)建立深厚信任和超高粘性。对供应商,则秉持公平合作,不拖欠货款,确保合理利润。这种文化基因是其他企业难以简单复制的。
|
||||
</li>
|
||||
<li>
|
||||
**精细化运营与高效供应链:** 胖东来通过“制造型零售商”的模式,将商品力做到极致。它以<strong class="text-neon">80%</strong>的自采比例,结合“四方联采”模式降低采购成本;投入<strong class="text-neon">1000万-1700万元</strong>建立独立质检机构,确保商品品质远超国标;投资<strong class="text-neon">6.6亿元</strong>建设中央厨房,实现生鲜、熟食的加工和日配;SKU精简至<strong class="text-neon">1.1万-1.5万个</strong>,聚焦高频刚需和高性价比商品。自有品牌(如白酒、啤酒、烘焙)贡献了<strong class="text-neon">30%-40%的销售额</strong>,部分自有品牌毛利率高达<strong class="text-neon">60%</strong>,同时通过“价格透明化”策略,增强了消费者信任。
|
||||
</li>
|
||||
<li>
|
||||
**极致体验的场景营造:** 胖东来的门店设计、陈列和服务细节,都旨在为消费者提供超出预期的购物体验。宽敞的通道(超<strong class="text-neon">4米</strong>)、取消主通道堆头、对标日本超市的硬件投入(智能卫生间、母婴室、直饮水)、场景化陈列(饮料按颜色分类、红酒搭配餐食推荐)等,都将购物场所升级为舒适、愉悦的生活空间,促使顾客将其视为目的地而非单纯的购物点。
|
||||
</li>
|
||||
<li>
|
||||
**强大的盈利能力与行业影响力:** 胖东来自身展现出惊人的业绩增长,2024年销售额近<strong class="text-neon">170亿元</strong>,利润超<strong class="text-neon">8亿元</strong>,人效、坪效均居中国民营企业第一。其对步步高、永辉等传统零售企业的成功调改,证明了其模式不仅能带来销售额的爆发式增长,更能提升毛利率、优化运营效率,为深陷亏损的行业带来转型希望。这使得胖东来从一个区域品牌,逐步成为整个零售行业学习和效仿的标杆。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">市场热度与情绪</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**新闻热度:** 关于胖东来自身业绩、扩张计划以及对其他企业调改进展的报道频繁且正面,引发社会广泛讨论。
|
||||
</li>
|
||||
<li>
|
||||
**研报密集度:** 多个券商、研究机构发布报告,深入剖析胖东来模式,将其视为零售业的“解药”和“教科书”,为市场提供了理论支撑。
|
||||
</li>
|
||||
<li>
|
||||
**资本市场反应:** 关联个股如永辉超市、步步高等因调改消息股价上涨。甚至出现<strong class="text-neon">酒鬼酒 (000799.SZ)</strong> 因传闻获得胖东来渠道支持而涨停的现象,显示了资金对“胖东来赋能”的高度认可。
|
||||
</li>
|
||||
<li>
|
||||
**消费者行为:** 大量外地消费者涌入许昌、新乡进行“旅游式购物”,导致门店限流,这体现了其强大的品牌号召力和消费者吸引力。
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-4 text-gray-300">总体而言,市场情绪偏向乐观,认为胖东来模式为传统零售业提供了一条可行的转型升级路径。</p>
|
||||
|
||||
<h3 class="text-xl font-bold mb-3 mt-6 text-neon">预期差分析</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**市场普遍认知:** 胖东来模式是一种“灵丹妙药”,可以快速复制到任何陷入困境的零售企业,带来立竿见影的业绩改善,且可以实现大规模的全国性推广。
|
||||
</li>
|
||||
<li>
|
||||
**潜在预期差:**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**文化和价值观复制的深度:** 市场可能低估了文化适配和“老板认知”在模式复制中的决定性作用。
|
||||
</li>
|
||||
<li>
|
||||
**供应链规模化与跨区域适应性:** 胖东来当前的供应链优势主要体现在河南区域,其自有品牌爆品的产能也存在瓶颈。
|
||||
</li>
|
||||
<li>
|
||||
**创始人依赖性与管理传承:** 胖东来的成功与创始人于东来的远见卓识和个人领导力紧密相关。
|
||||
</li>
|
||||
<li>
|
||||
**长期盈利的持续性:** 调改门店在初期通常能实现销售额的爆发式增长,但这种增长的持续性以及在胖东来团队撤离后,被调改企业能否依靠自身力量维持这种运营水平和盈利能力,仍需时间检验。
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section: 3. 关键催化剂与未来发展路径 -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">3. 关键催化剂与未来发展路径</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">近期催化剂 (未来3-6个月)</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**永辉超市调改门店的持续业绩表现:** 计划2025年累计完成<strong class="text-neon">180家</strong>调改店,持续验证模式可复制性。
|
||||
</li>
|
||||
<li>
|
||||
**胖东来郑州首店的具体进展:** 预计<strong class="text-neon">2026年元旦前正式亮相</strong>,验证跨区域运营能力。
|
||||
</li>
|
||||
<li>
|
||||
**其他零售企业调改案例的落地与初期效果:** 中百集团、汇嘉时代等企业调改方案和业绩表现。
|
||||
</li>
|
||||
<li>
|
||||
**胖东来网上课堂的推出:** 加速胖东来理念传播,扩大行业影响力。
|
||||
</li>
|
||||
<li>
|
||||
**“梦之城”等大型投资项目的实质性进展:** 强化市场对自身多元化发展的预期。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">长期发展路径</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**区域深耕与多业态融合:** 通过“梦之城”等项目,深化核心区域精耕,打造综合性城市生活中心。
|
||||
</li>
|
||||
<li>
|
||||
**模式输出标准化与可持续商业化:** 逐步探索标准化、商业化的模式输出机制,推广“学校”理念。
|
||||
</li>
|
||||
<li>
|
||||
**自有品牌与供应链生态圈构建:** 持续加大自有品牌投入(目标未来3年占比提升至<strong class="text-neon">20%</strong>),形成以自身为核心的供应链生态圈。
|
||||
</li>
|
||||
<li>
|
||||
**数字化与智能化转型:** 利用大数据、AI、物联网等提升运营效率和客户体验。
|
||||
</li>
|
||||
<li>
|
||||
**从“区域品牌”到“行业思想源泉”:** 成为中国零售业转型升级的“思想源泉”和“实践标杆”。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section: 4. 产业链与核心公司深度剖析 -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">4. 产业链与核心公司深度剖析</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">产业链图谱</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**上游 (供应商与生产商):**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**自采商品供应商:** 提供各类商品,胖东来严选,账期<strong class="text-neon">30天</strong>。
|
||||
</li>
|
||||
<li>
|
||||
**自有品牌生产商:** 为“德丽可思”、“高兴”系列等自有品牌提供原材料或代工。
|
||||
</li>
|
||||
<li>
|
||||
**中央厨房与物流:** 投资<strong class="text-neon">6.6亿元</strong>中央厨房,<strong class="text-neon">15亿元</strong>物流园。
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
**中游 (零售渠道与运营):**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**胖东来自身门店:** 许昌、新乡共<strong class="text-neon">13家实体门店</strong>。
|
||||
</li>
|
||||
<li>
|
||||
**学习/被帮扶企业:**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**深度调改者:** <strong class="text-neon">永辉超市 (601933.SH)</strong>、<strong class="text-neon">步步高 (002251.SZ)</strong>、<strong class="text-neon">新华百货 (600785.SH)</strong>。
|
||||
</li>
|
||||
<li>
|
||||
**积极学习者:** <strong class="text-neon">中百集团 (000759.SH)</strong>、<strong class="text-neon">汇嘉时代 (603101.SH)</strong>、<strong class="text-neon">重庆百货 (600729.SH)</strong> 等。
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
**下游 (消费者):**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**本地居民**
|
||||
</li>
|
||||
<li>
|
||||
**外地“朝圣”游客**
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">核心玩家对比</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**胖东来 (非上市公司) - 领导者:**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**竞争优势:** “自由、爱”文化,高员工积极性,极致客户服务,高效供应链。<strong class="text-neon">人效120万元/人/年</strong>,<strong class="text-neon">坪效120万元/年</strong>,均居中国民营企业第一。
|
||||
</li>
|
||||
<li>
|
||||
**业务进展:** 2024年销售<strong class="text-neon">169.64亿元</strong>,利润<strong class="text-neon">8亿多元</strong>,积极区域扩张。
|
||||
</li>
|
||||
<li>
|
||||
**潜在风险:** 创始人依赖性、文化深层次复制难度、供应链产能瓶颈。
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
**永辉超市 (601933.SH) - 深度变革者:**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**竞争优势:** 全国性大型连锁超市,拥有庞大的门店网络和相对完善的生鲜供应链。拥有庞大的改造空间。
|
||||
</li>
|
||||
<li>
|
||||
**业务进展:** 已完成<strong class="text-neon">124家</strong>门店调改,并计划推广至<strong class="text-neon">180家</strong>门店。调改后门店销售额和客流均有显著提升(日销从<strong class="text-neon">15-16万元</strong>增至<strong class="text-neon">200万元</strong>)。
|
||||
</li>
|
||||
<li>
|
||||
**潜在风险:** 自身体量巨大,全面转型难度高;供应链整合挑战(需兼容胖东来标准与自身原有体系);模式深层次落地仍需时间检验;此前业绩亏损严重,持续盈利能力需观察。
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
**步步高 (002251.SZ) - 成功样本:**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**竞争优势:** 在湖南区域有一定市场基础,且是首个被胖东来成功调改的典型案例,为行业提供了直接的成功样本。
|
||||
</li>
|
||||
<li>
|
||||
**业务进展:** 长沙梅溪湖店调改后日销增长近<strong class="text-neon">9倍</strong>,员工薪资普遍提升<strong class="text-neon">30%</strong>。
|
||||
</li>
|
||||
<li>
|
||||
**潜在风险:** 自身曾面临退市危机,财务基础较弱;百货业态改造难度大;长期业绩持续性和胖东来团队撤离后的自主运营能力需重点关注。
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
**新华百货 (600785.SH) - 区域示范者:**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**竞争优势:** 在宁夏区域市场具备领先优势,且调改费用低于行业平均水平。
|
||||
</li>
|
||||
<li>
|
||||
**业务进展:** 吴忠万达店调改成功,日销提升<strong class="text-neon">7-9倍</strong>,证明了胖东来模式在低能级城市及区域性零售企业中的可复制性。
|
||||
</li>
|
||||
<li>
|
||||
**潜在风险:** 地域性限制,规模相对较小;后续改造的范围和深度。
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
**酒鬼酒 (000799.SZ) - 间接受益者:**
|
||||
<ul class="list-none pl-4 mt-2">
|
||||
<li>
|
||||
**竞争优势:** 知名白酒品牌,产品线(如内参/红坛)定位与胖东来主销的<strong class="text-neon">300-800元</strong>价格带高度吻合,无需再教育市场,可直接上量。公司产能充裕,基酒储备充足。
|
||||
</li>
|
||||
<li>
|
||||
**业务进展:** 因“胖东来渠道”传闻,股价出现异动。若胖东来13家门店批量进货属实,理论上可带来<strong class="text-neon">15-20亿元</strong>的年销售额,相当于2024全年营收<strong class="text-neon">28亿元</strong>的<strong class="text-neon">50%以上</strong>。
|
||||
</li>
|
||||
<li>
|
||||
**潜在风险:** 销售渠道新增的真实性及规模效应有待进一步确认,短期股价涨幅可能已透支部分利好,白酒行业整体竞争依然激烈。
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-xl font-bold mb-3 mt-6 text-neon">验证与证伪</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**研报与新闻中的逻辑:** 胖东来模式的核心在于“人”(员工、顾客)和“货”(供应链、自有品牌),通过“帮扶”能够赋能其他零售企业,实现业绩反转。
|
||||
</li>
|
||||
<li>
|
||||
**数据印证:** 员工薪酬福利提升,商品结构优化,以及调改门店销售额、客流、客单价的显著增长。
|
||||
</li>
|
||||
<li>
|
||||
**潜在风险点(证伪迹象需关注):** 2023年财务数据矛盾,调改门店业绩回落,自有品牌水土不服,合作方销售数据不及预期。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section: 5. 潜在风险与挑战 -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">5. 潜在风险与挑战</h2>
|
||||
<ul class="list-none pl-0 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<li class="glass-card p-4">
|
||||
<strong class="text-neon">模式深度复制风险:</strong> 核心在于独特的“自由、爱”企业文化和创始人魅力,难以简单复制,恐“形似神不似”。
|
||||
</li>
|
||||
<li class="glass-card p-4">
|
||||
<strong class="text-neon">供应链规模化与跨区域适应性风险:</strong> 河南区域供应链难以支持全国,自有品牌产能瓶颈,跨区域接受度挑战。
|
||||
</li>
|
||||
<li class="glass-card p-4">
|
||||
<strong class="text-neon">创始人依赖与管理传承风险:</strong> 高度依赖于东来个人,未来管理层传承和核心理念延续性存在风险。
|
||||
</li>
|
||||
<li class="glass-card p-4">
|
||||
<strong class="text-neon">市场竞争加剧与消费者预期管理:</strong> 成功引来模仿者,消费者高预期下服务/商品偏差易引发负面舆情。
|
||||
</li>
|
||||
<li class="glass-card p-4">
|
||||
<strong class="text-neon">政策与监管风险:</strong> 宏观经济、消费政策影响,反代购等措施需在合规与顾客体验间平衡。
|
||||
</li>
|
||||
<li class="glass-card p-4">
|
||||
<strong class="text-neon">信息交叉验证风险:</strong> 2023年财务数据在不同报告中存在差异,可能导致误判。
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Section: 6. 综合结论与投资启示 -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">6. 综合结论与投资启示</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">概念所处阶段</h3>
|
||||
<p class="text-gray-300 leading-relaxed">
|
||||
零售百货胖东来概念已从最初的主题炒作阶段,逐步迈入<strong class="text-neon">基本面驱动阶段</strong>。其自身强大的业绩增长(2024年销售额达<strong class="text-neon">169.64亿元</strong>,利润<strong class="text-neon">8亿多元</strong>)、清晰的区域扩张计划(郑州店、梦之城)以及成功赋能其他企业的案例,都为概念提供了坚实的基本面支撑。然而,市场对其模式输出的广度和深度、以及文化复制的难度仍存在一定“预期差”,部分短期股价异动仍带有主题性炒作的色彩。可以认为,概念正处于“<strong class="text-neon">基本面持续验证+主题性溢价仍存</strong>”的阶段。
|
||||
</p>
|
||||
</div>
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">最具投资价值的细分环节或方向</h3>
|
||||
<ul class="list-none pl-0">
|
||||
<li>
|
||||
**直接受益于胖东来模式输出的头部零售企业:** <strong class="text-neon">永辉超市 (601933.SH)</strong> 和 <strong class="text-neon">步步高 (002251.SZ)</strong>。
|
||||
</li>
|
||||
<li>
|
||||
**与胖东来形成深度供应链合作的品牌商:** 如<strong class="text-neon">酒鬼酒 (000799.SZ)</strong>。
|
||||
</li>
|
||||
<li>
|
||||
**积极学习和拥抱胖东来模式的区域性零售龙头:** 如<strong class="text-neon">新华百货 (600785.SH)</strong> 等。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="glass-card p-6 lg:col-span-2">
|
||||
<h3 class="text-xl font-bold mb-3 text-neon">接下来需要重点跟踪和验证的关键指标</h3>
|
||||
<ul class="list-none pl-0 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<li>
|
||||
**被调改门店的持续盈利能力和销售增长率:** 尤其是在胖东来团队撤离后的自主运营表现。
|
||||
</li>
|
||||
<li>
|
||||
**永辉超市调改门店的数量、地理覆盖广度以及销售额增量:** 关注其“180家”年度目标的完成情况。
|
||||
</li>
|
||||
<li>
|
||||
**胖东来自有品牌在被调改门店中的渗透率和销售贡献:** 反映供应链赋能深度。
|
||||
</li>
|
||||
<li>
|
||||
**胖东来郑州首店及“梦之城”项目的建设和运营数据:** 验证跨区域和大型综合体运营能力。
|
||||
</li>
|
||||
<li>
|
||||
**创始人于东来及核心管理层对模式输出和企业发展的战略表态:** 关注对未来路径的明确。
|
||||
</li>
|
||||
<li>
|
||||
**酒鬼酒等合作方的销售数据及官方确认:** 验证胖东来渠道的实际销售拉动效果。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Echarts Visualization - Financial Performance -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">财务表现与员工福利洞察</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="glass-card p-6" x-data="{ chart: null }" x-init="
|
||||
chart = echarts.init($refs.salesChart, 'dark', { renderer: 'canvas' });
|
||||
chart.setOption({
|
||||
title: { text: '胖东来集团销售额及增长', textStyle: { color: '#C0DEFF' } },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: ['销售额 (亿元)', '同比增长 (%)'], textStyle: { color: '#E0E7FF' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['2023年', '2024年', '2025年(预估)'],
|
||||
axisLabel: { color: '#E0E7FF' }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '销售额 (亿元)',
|
||||
axisLabel: { formatter: '{value} 亿', color: '#E0E7FF' },
|
||||
nameTextStyle: { color: '#E0E7FF' },
|
||||
splitLine: { lineStyle: { color: 'rgba(192, 222, 255, 0.1)' } }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '同比增长 (%)',
|
||||
axisLabel: { formatter: '{value} %', color: '#E0E7FF' },
|
||||
nameTextStyle: { color: '#E0E7FF' },
|
||||
splitLine: { lineStyle: { color: 'rgba(192, 222, 255, 0.1)' } }
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '销售额 (亿元)',
|
||||
type: 'bar',
|
||||
data: [107, 169.64, 200], /* 2023 derived from news 2024 growth: 169.64 / (1 + 0.5854) = 106.99 (approx 107) */
|
||||
itemStyle: { color: '#6EE7B7' }
|
||||
},
|
||||
{
|
||||
name: '同比增长 (%)',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: [null, 58.54, (200-169.64)/169.64*100],
|
||||
itemStyle: { color: '#8BE9FD' }
|
||||
}
|
||||
]
|
||||
});
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
">
|
||||
<div ref="salesChart" class="w-full h-80"></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6" x-data="{ chart: null }" x-init="
|
||||
chart = echarts.init($refs.employeeChart, 'dark', { renderer: 'canvas' });
|
||||
chart.setOption({
|
||||
title: { text: '员工平均月收入趋势', textStyle: { color: '#C0DEFF' } },
|
||||
tooltip: { trigger: 'axis', formatter: '{b}<br/>{a}: {c} 元' },
|
||||
legend: { data: ['平均月收入 (元)'], textStyle: { color: '#E0E7FF' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['2024年', '2025年1-2月'],
|
||||
axisLabel: { color: '#E0E7FF' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '收入 (元)',
|
||||
axisLabel: { formatter: '{value} 元', color: '#E0E7FF' },
|
||||
nameTextStyle: { color: '#E0E7FF' },
|
||||
splitLine: { lineStyle: { color: 'rgba(192, 222, 255, 0.1)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '平均月收入 (元)',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [9000, 9886],
|
||||
itemStyle: { color: '#6EE7B7' },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{
|
||||
offset: 0, color: 'rgba(110, 231, 183, 0.5)'
|
||||
}, {
|
||||
offset: 1, color: 'rgba(110, 231, 183, 0)'
|
||||
}]) }
|
||||
}
|
||||
]
|
||||
});
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
">
|
||||
<div ref="employeeChart" class="w-full h-80"></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6 lg:col-span-2" x-data="{ chart: null }" x-init="
|
||||
chart = echarts.init($refs.profitChart, 'dark', { renderer: 'canvas' });
|
||||
chart.setOption({
|
||||
title: { text: '胖东来集团利润及净利率', textStyle: { color: '#C0DEFF' } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['利润 (亿元)', '净利率 (%)'], textStyle: { color: '#E0E7FF' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['2023年', '2024年', '2025年(预估)'],
|
||||
axisLabel: { color: '#E0E7FF' }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '利润 (亿元)',
|
||||
axisLabel: { formatter: '{value} 亿', color: '#E0E7FF' },
|
||||
nameTextStyle: { color: '#E0E7FF' },
|
||||
splitLine: { lineStyle: { color: 'rgba(192, 222, 255, 0.1)' } }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '净利率 (%)',
|
||||
axisLabel: { formatter: '{value} %', color: '#E0E7FF' },
|
||||
nameTextStyle: { color: '#E0E7FF' },
|
||||
splitLine: { lineStyle: { color: 'rgba(192, 222, 255, 0.1)' } }
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '利润 (亿元)',
|
||||
type: 'bar',
|
||||
data: [1.4, 8, 15], /* News data: 2023 profit 1.4B, 2024 profit 8B, 2025 profit 15B */
|
||||
itemStyle: { color: '#A7F3D0' }
|
||||
},
|
||||
{
|
||||
name: '净利率 (%)',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: [1.4/107*100, 8/169.64*100, 15/200*100], /* 2023: 1.4/107 = 1.3%, 2024: 8/169.64 = 4.7%, 2025: 15/200 = 7.5% */
|
||||
itemStyle: { color: '#8BE9FD' }
|
||||
}
|
||||
]
|
||||
});
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
">
|
||||
<div ref="profitChart" class="w-full h-80"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Echarts Visualization - Store Sales Before/After Modification -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">调改门店效果对比</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="glass-card p-6" x-data="{ chart: null }" x-init="
|
||||
chart = echarts.init($refs.bubugaoChart, 'dark', { renderer: 'canvas' });
|
||||
chart.setOption({
|
||||
title: { text: '步步高长沙梅溪湖店日均销售额', textStyle: { color: '#C0DEFF' } },
|
||||
tooltip: { trigger: 'axis', formatter: '{b}<br/>{a}: {c} 万元' },
|
||||
legend: { data: ['日均销售额 (万元)'], textStyle: { color: '#E0E7FF' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['调改前', '调改后'],
|
||||
axisLabel: { color: '#E0E7FF' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '销售额 (万元)',
|
||||
axisLabel: { formatter: '{value} 万', color: '#E0E7FF' },
|
||||
nameTextStyle: { color: '#E0E7FF' },
|
||||
splitLine: { lineStyle: { color: 'rgba(192, 222, 255, 0.1)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '日均销售额 (万元)',
|
||||
type: 'bar',
|
||||
data: [15, 130],
|
||||
itemStyle: { color: '#FCD34D' } /* Yellow for contrast */
|
||||
}
|
||||
]
|
||||
});
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
">
|
||||
<div ref="bubugaoChart" class="w-full h-80"></div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6" x-data="{ chart: null }" x-init="
|
||||
chart = echarts.init($refs.yonghuiChart, 'dark', { renderer: 'canvas' });
|
||||
chart.setOption({
|
||||
title: { text: '永辉超市郑州瀚海海尚店日销', textStyle: { color: '#C0DEFF' } },
|
||||
tooltip: { trigger: 'axis', formatter: '{b}<br/>{a}: {c} 万元' },
|
||||
legend: { data: ['日均销售额 (万元)'], textStyle: { color: '#E0E7FF' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['调改前', '调改后(峰值)'],
|
||||
axisLabel: { color: '#E0E7FF' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '销售额 (万元)',
|
||||
axisLabel: { formatter: '{value} 万', color: '#E0E7FF' },
|
||||
nameTextStyle: { color: '#E0E7FF' },
|
||||
splitLine: { lineStyle: { color: 'rgba(192, 222, 255, 0.1)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '日均销售额 (万元)',
|
||||
type: 'bar',
|
||||
data: [16, 200],
|
||||
itemStyle: { color: '#EC4899' } /* Pink for contrast */
|
||||
}
|
||||
]
|
||||
});
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
">
|
||||
<div ref="yonghuiChart" class="w-full h-80"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stock Data Table -->
|
||||
<section class="glass-card mb-8">
|
||||
<h2 class="text-3xl font-bold mb-6 text-neon">关联股票数据</h2>
|
||||
<div class="overflow-x-auto w-full">
|
||||
<table class="table w-full table-zebra table-glass">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-neon">股票名称</th>
|
||||
<th class="text-neon">股票代码</th>
|
||||
<th class="text-neon">关联理由</th>
|
||||
<th class="text-neon">其他标签</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>永辉超市</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=601933" target="_blank" class="code-link">601933</a></td>
|
||||
<td>公司以胖东来模式对门店进行调改,截至2025年6月30日,调改开业门店共计124家。募投项目,共涉及216家门店的调改</td>
|
||||
<td>胖东来相关, 福建</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>步步高</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002251" target="_blank" class="code-link">002251</a></td>
|
||||
<td>公司借助胖东来帮扶契机,全面导入胖东来先进的文化理念</td>
|
||||
<td>胖东来相关, 湖南</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>中百集团</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000759" target="_blank" class="code-link">000759</a></td>
|
||||
<td>胖东来创始人于东来曾到访中百集团;截至2025年4月30日公司已闭店调改仓储大卖场12家、社区超市42家,2025年调改工作还将持续进行</td>
|
||||
<td>胖东来相关, 湖北</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>汇嘉时代</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=603101" target="_blank" class="code-link">603101</a></td>
|
||||
<td>公司多角度全方位学习胖东来,调改门店布局</td>
|
||||
<td>胖东来相关, 新疆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>重庆百货</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600729" target="_blank" class="code-link">600729</a></td>
|
||||
<td>公司多次组织各层级团队前往胖东来交流学习,积极推动超市变革</td>
|
||||
<td>胖东来相关, 重庆</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>银座股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600858" target="_blank" class="code-link">600858</a></td>
|
||||
<td>即食品类方面,公司重点对标超市包括胖东来,目前新调改门店</td>
|
||||
<td>胖东来相关, 山东</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>新华百货</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600785" target="_blank" class="code-link">600785</a></td>
|
||||
<td>公司正全方位深入学习和借鉴胖东来超市的服务、管理及经营模式等,后续将实施针对性改造优化</td>
|
||||
<td>胖东来相关, 宁夏</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>供销大集</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000564" target="_blank" class="code-link">000564</a></td>
|
||||
<td>公司对于包括胖东来在内的优秀模式保持研究学习</td>
|
||||
<td>胖东来相关</td>
|
||||
</tr>
|
||||
<!-- Additional stocks from the data provided, keeping only relevant ones and adding links -->
|
||||
<tr>
|
||||
<td>酒鬼酒</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000799" target="_blank" class="code-link">000799</a></td>
|
||||
<td>核心结论: 盘中确认“胖东来系统已大批量进货酒鬼酒”,新增渠道短期可贡献≈50%营收弹性,叠加白酒板块左侧拐点预期,资金把公司当成“困境反转+渠道爆量”双击标的打板。</td>
|
||||
<td>白酒+胖东来渠道扩张</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>东百集团</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600693" target="_blank" class="code-link">600693</a></td>
|
||||
<td>公司地处福州,公司核心门店占据福州、兰州历史文化核心商圈</td>
|
||||
<td>福建</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>南京商旅</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600250" target="_blank" class="code-link">600250</a></td>
|
||||
<td>公司地处南京,子公司拥有南京商厦20年特许经营权,是南京城北地区第一家综合购物百货商场;南京国资委持股34.03%</td>
|
||||
<td>江苏</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>中央商场</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600280" target="_blank" class="code-link">600280</a></td>
|
||||
<td>公司地处南京,公司为“中华老字号”百货企业,旗下百货子公司均为当地传统百货龙头企业</td>
|
||||
<td>江苏</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>南京新百</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600682" target="_blank" class="code-link">600682</a></td>
|
||||
<td>公司地处南京,公司新百中心店地处南京市新街口核心地段</td>
|
||||
<td>江苏</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>友阿股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002277" target="_blank" class="code-link">002277</a></td>
|
||||
<td>公司地处长沙,拥有多个知名商业品牌,并获得7-ELEVEN便利店湖南省特许经营权</td>
|
||||
<td>湖南</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>通程控股</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000419" target="_blank" class="code-link">000419</a></td>
|
||||
<td>公司地处长沙,公司综合市场占有率在区域市场一直保持领先;长沙国资委持股15.52%</td>
|
||||
<td>湖南</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>武商集团</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000501" target="_blank" class="code-link">000501</a></td>
|
||||
<td>公司地处武汉,位列2024年中国连锁百强榜单第17位、中国零售百强榜单第17位的排名;武汉国资委持股26.64%</td>
|
||||
<td>湖北</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>国光连锁</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=605188" target="_blank" class="code-link">605188</a></td>
|
||||
<td>公司地处江西吉安市,为江西本土领先的零售连锁企业</td>
|
||||
<td>江西</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>茂业商业</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600828" target="_blank" class="code-link">600828</a></td>
|
||||
<td>公司地处成都,公司为大西南区最具影响力的百货零售巨头</td>
|
||||
<td>四川</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>红旗连锁</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002697" target="_blank" class="code-link">002697</a></td>
|
||||
<td>公司为零售龙头企业;四川国资委持股15.22%</td>
|
||||
<td>四川</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>徐家汇</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002561" target="_blank" class="code-link">002561</a></td>
|
||||
<td>公司主要门店处于上海市级商业中心的黄金地段;上海徐汇国资委持股30.37%</td>
|
||||
<td>上海</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>益民集团</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600824" target="_blank" class="code-link">600824</a></td>
|
||||
<td>公司旗下拥有“古今内衣”、“天宝龙凤”、“星光摄影”等沪上知名品牌商标;上海黄浦国资委持股39.32%</td>
|
||||
<td>上海</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>新世界</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600628" target="_blank" class="code-link">600628</a></td>
|
||||
<td>“新世界城”为中国上海零售业老字号知名品牌;上海黄浦国资委持股28.4%</td>
|
||||
<td>上海</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>上海九百</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600838" target="_blank" class="code-link">600838</a></td>
|
||||
<td>公司前身为创建于1939年的百乐商场;公司参投的“九百城市广场”及“久光百货”为静安寺商圈地标性商业百货;上海静安国资委持股32.59%</td>
|
||||
<td>上海</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>百联股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600827" target="_blank" class="code-link">600827</a></td>
|
||||
<td>公司作为上海的商业名片,公司旗下拥有第一八佰伴、永安百货、东方商厦(旗舰店)等多个知名商业品牌;上海国资委持股34.88%</td>
|
||||
<td>上海</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>三江购物</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=601116" target="_blank" class="code-link">601116</a></td>
|
||||
<td>公司地处宁波,2025年H1门店185家</td>
|
||||
<td>浙江</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>宁波中百</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600857" target="_blank" class="code-link">600857</a></td>
|
||||
<td>公司地处宁波,公司所持有的品牌“宁波二百”位列“2025宁波消费渠道品牌榜TOP10”</td>
|
||||
<td>浙江</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>杭州解百</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600814" target="_blank" class="code-link">600814</a></td>
|
||||
<td>公司地处杭州,公司及旗下企业被商务部评定为“金鼎”百货,在区域内具有较强的竞争力;杭州国资委持股60.06%</td>
|
||||
<td>浙江</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>百大集团</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600865" target="_blank" class="code-link">600865</a></td>
|
||||
<td>公司地处杭州,公司将杭州百货大楼委托给银泰百货运营管理至2028年2月29日</td>
|
||||
<td>浙江</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>王府井</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600859" target="_blank" class="code-link">600859</a></td>
|
||||
<td>公司共运营79家大型零售门店,专业店349家;北京国资委持股29.85%</td>
|
||||
<td>北京</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>翠微股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=603123" target="_blank" class="code-link">603123</a></td>
|
||||
<td>公司在北京拥有翠微百货翠微店(A、B座)等7家门店;海淀国资委持股48.96%</td>
|
||||
<td>北京</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>华联股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000882" target="_blank" class="code-link">000882</a></td>
|
||||
<td>公司地处北京西城区,公司控股股东北京华联集团为知名大型零售企业</td>
|
||||
<td>北京</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>广百股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=002187" target="_blank" class="code-link">002187</a></td>
|
||||
<td>公司是广东省百货连锁龙头企业;广州国资委持股41.83%</td>
|
||||
<td>广东</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>南宁百货</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600712" target="_blank" class="code-link">600712</a></td>
|
||||
<td>公司地处南宁,开设有11家实体门店,在南宁市、贺州市等广西部分市县,综合实力位列广西商业前列;南宁国资委持股28.59%</td>
|
||||
<td>广西</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>大商股份</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600694" target="_blank" class="code-link">600694</a></td>
|
||||
<td>公司地处大连,公司以百货、超市、电器为核心,核心区域城市商圈渗透率超过75%,社区商业布局位居行业前三</td>
|
||||
<td>辽宁</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>大连友谊</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000679" target="_blank" class="code-link">000679</a></td>
|
||||
<td>公司地处大连,公司现有零售门店位于大连核心商圈</td>
|
||||
<td>辽宁</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>中兴商业</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000715" target="_blank" class="code-link">000715</a></td>
|
||||
<td>公司地处沈阳,公司商业零售主要分布于沈阳市核心商圈</td>
|
||||
<td>辽宁</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>家家悦</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=603708" target="_blank" class="code-link">603708</a></td>
|
||||
<td>公司地处威海,2025年H1公司门店总数1,084家,其中直营门店928家、加盟店156家</td>
|
||||
<td>山东</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>欧亚集团</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=600697" target="_blank" class="code-link">600697</a></td>
|
||||
<td>公司地处长春,坐拥近150家门店;长春国资委持股24.54%</td>
|
||||
<td>吉林</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>合百集团</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=000417" target="_blank" class="code-link">000417</a></td>
|
||||
<td>公司地处合肥,为安徽区域市场零售龙头企业;合肥国资委持股38%</td>
|
||||
<td>安徽</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>国芳集团</td>
|
||||
<td><a href="https://valuefrontier.cn/company?scode=601086" target="_blank" class="code-link">601086</a></td>
|
||||
<td>公司为兰州市零售行业龙头</td>
|
||||
<td>甘肃</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 438 KiB |
BIN
public/img/aftership.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/img/anthropic.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/img/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/img/asana.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
14
public/img/benefit-increase.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_223_1402)">
|
||||
<path d="M7.72645 3.98828C7.41145 3.65328 7.42645 3.12578 7.76145 2.80995L9.74561 0.939948C10.3364 0.347448 11.3281 0.348281 11.9373 0.957448L13.9039 2.80995C14.2389 3.12578 14.2548 3.65328 13.9389 3.98828C13.7748 4.16245 13.5539 4.24995 13.3323 4.24995C13.1273 4.24995 12.9214 4.17495 12.7606 4.02328L11.6656 2.99161V7.16661C11.6656 7.62661 11.2923 7.99995 10.8323 7.99995C10.3723 7.99995 9.99895 7.62661 9.99895 7.16661V2.99078L8.90395 4.02328C8.56978 4.33745 8.04145 4.32411 7.72561 3.98828H7.72645ZM1.40478 5.68995L2.49978 4.65745V7.16661C2.49978 7.62661 2.87311 7.99995 3.33311 7.99995C3.79311 7.99995 4.16645 7.62661 4.16645 7.16661V4.65828L5.26145 5.68995C5.42228 5.84161 5.62728 5.91662 5.83311 5.91662C6.05478 5.91662 6.27561 5.82911 6.43978 5.65495C6.75478 5.31995 6.73978 4.79245 6.40478 4.47661L4.43811 2.62411C3.82811 2.01495 2.83811 2.01411 2.24645 2.60661L0.261446 4.47661C-0.0735537 4.79245 -0.089387 5.31995 0.226446 5.65495C0.54228 5.99078 1.07061 6.00495 1.40478 5.68995ZM19.3631 11.3458L13.6923 17.7108C12.1123 19.4841 9.84395 20.5008 7.47061 20.5008H3.33311C1.49478 20.5008 -0.000220327 19.0058 -0.000220327 17.1674V13.0008C-0.000220327 11.1624 1.49478 9.66745 3.33311 9.66745H10.7148C11.6639 9.66745 12.4964 10.1749 12.9556 10.9333L15.6356 7.98828C16.0873 7.49245 16.7039 7.20161 17.3739 7.17078C18.0473 7.13578 18.6856 7.37078 19.1806 7.82245C20.1923 8.74495 20.2739 10.3249 19.3631 11.3458ZM18.0581 9.05328C17.8923 8.90161 17.6756 8.82495 17.4514 8.83495C17.2264 8.84578 17.0198 8.94245 16.8681 9.10911L13.1798 13.1624C12.8589 14.0541 12.0639 14.7383 11.0839 14.8783L6.78395 15.4924C6.32978 15.5583 5.90645 15.2416 5.84145 14.7849C5.77645 14.3291 6.09311 13.9066 6.54811 13.8416L10.8489 13.2274C11.3156 13.1616 11.6664 12.7566 11.6664 12.2858C11.6664 11.7608 11.2398 11.3333 10.7148 11.3333H3.33311C2.41395 11.3333 1.66645 12.0808 1.66645 12.9999V17.1666C1.66645 18.0858 2.41395 18.8333 3.33311 18.8333H7.47061C9.36978 18.8333 11.1839 18.0191 12.4481 16.6016L18.1189 10.2366C18.4248 9.89328 18.3973 9.36245 18.0581 9.05245V9.05328Z" fill="url(#paint0_linear_223_1402)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_223_1402" x1="9.99786" y1="0.498047" x2="9.99786" y2="20.5008" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_223_1402">
|
||||
<rect width="20" height="20" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/img/benefits-pic.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/img/calendly.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/img/cerebras.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
9
public/img/chart.svg
Normal file
|
After Width: | Height: | Size: 56 KiB |
14
public/img/clock-up.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_223_1412)">
|
||||
<path d="M10 4.16667C10.4608 4.16667 10.8333 4.53917 10.8333 5V9.89667C10.8333 10.1875 10.6825 10.4558 10.4342 10.6083L6.76083 12.855C6.62583 12.9383 6.475 12.9767 6.32667 12.9767C6.04583 12.9767 5.7725 12.835 5.615 12.5775C5.375 12.185 5.49833 11.6725 5.89167 11.4317L9.16667 9.42917V5C9.16667 4.53917 9.53917 4.16667 10 4.16667ZM18.3333 10.8333H15C14.5392 10.8333 14.1667 11.2058 14.1667 11.6667C14.1667 12.1275 14.5392 12.5 15 12.5H17.14L13.5325 16.1067C13.3717 16.2675 13.0875 16.2683 12.925 16.1067L12.005 15.1867C11.2125 14.3925 9.83167 14.3925 9.03667 15.1867L5.65917 18.5783C5.33417 18.905 5.33583 19.4325 5.66167 19.7575C5.82417 19.9192 6.0375 20 6.25 20C6.46417 20 6.67833 19.9175 6.84083 19.755L10.2175 16.3633C10.3783 16.2025 10.6625 16.2017 10.825 16.3633L11.745 17.2833C12.5375 18.0775 13.92 18.0767 14.7117 17.2833L18.3333 13.6625V15.8325C18.3333 16.2933 18.7058 16.6658 19.1667 16.6658C19.6275 16.6658 20 16.2933 20 15.8325V12.4992C20 11.58 19.2525 10.8325 18.3333 10.8325V10.8333ZM1.66667 10C1.66667 5.405 5.405 1.66667 10 1.66667C14.0233 1.66667 17.47 4.53333 18.1958 8.48417C18.28 8.93667 18.7092 9.24167 19.1658 9.15333C19.6183 9.07 19.9175 8.63583 19.835 8.18333C18.9642 3.44167 14.8275 0 10 0C4.48583 0 0 4.48583 0 10C0 12.7633 1.16 15.43 3.18167 17.315C3.34167 17.465 3.54667 17.5392 3.75 17.5392C3.97333 17.5392 4.195 17.4508 4.35917 17.2742C4.67333 16.9383 4.65417 16.4108 4.3175 16.0967C2.63333 14.525 1.66583 12.3033 1.66583 10.0008L1.66667 10Z" fill="url(#paint0_linear_223_1412)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_223_1412" x1="10" y1="0" x2="10" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_223_1412">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
14
public/img/clock.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 0C8.02219 0 6.08879 0.58649 4.4443 1.6853C2.79981 2.78412 1.51809 4.3459 0.761209 6.17317C0.0043329 8.00043 -0.193701 10.0111 0.192152 11.9509C0.578004 13.8907 1.53041 15.6725 2.92894 17.0711C4.32746 18.4696 6.10929 19.422 8.0491 19.8079C9.98891 20.1937 11.9996 19.9957 13.8268 19.2388C15.6541 18.4819 17.2159 17.2002 18.3147 15.5557C19.4135 13.9112 20 11.9778 20 10C19.9971 7.34871 18.9426 4.80684 17.0679 2.9321C15.1932 1.05736 12.6513 0.00286757 10 0ZM10 18.3333C8.35183 18.3333 6.74066 17.8446 5.37025 16.9289C3.99984 16.0132 2.93174 14.7117 2.30101 13.189C1.67028 11.6663 1.50525 9.99076 1.82679 8.37425C2.14834 6.75774 2.94201 5.27288 4.10745 4.10744C5.27289 2.94201 6.75774 2.14833 8.37425 1.82679C9.99076 1.50525 11.6663 1.67027 13.189 2.301C14.7118 2.93173 16.0132 3.99984 16.9289 5.37025C17.8446 6.74066 18.3333 8.35182 18.3333 10C18.3309 12.2094 17.4522 14.3276 15.8899 15.8899C14.3276 17.4522 12.2094 18.3309 10 18.3333Z" fill="url(#paint0_linear_3_77)"/>
|
||||
<path d="M10 5C9.77899 5 9.56702 5.0878 9.41074 5.24408C9.25446 5.40036 9.16667 5.61232 9.16667 5.83333V9.4375L6.3575 11.1975C6.16964 11.3149 6.03609 11.502 5.98624 11.7179C5.93638 11.9337 5.97431 12.1605 6.09167 12.3483C6.20902 12.5362 6.3962 12.6697 6.61203 12.7196C6.82785 12.7694 7.05464 12.7315 7.2425 12.6142L10.4425 10.6142C10.5634 10.5384 10.6628 10.4329 10.7313 10.3077C10.7997 10.1825 10.8348 10.0418 10.8333 9.89917V5.83333C10.8333 5.61232 10.7455 5.40036 10.5893 5.24408C10.433 5.0878 10.221 5 10 5Z" fill="url(#paint1_linear_3_77)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3_77" x1="10" y1="0" x2="10" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3_77" x1="10" y1="0" x2="10" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
14
public/img/comments.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_224_1740)">
|
||||
<path d="M20 13.8334V18C20 18.6631 19.7366 19.299 19.2678 19.7678C18.7989 20.2366 18.163 20.5 17.5 20.5H13.3333C12.1642 20.4988 11.016 20.1902 10.0039 19.6052C8.99172 19.0201 8.15121 18.1792 7.56667 17.1667C8.19489 17.1622 8.82098 17.0929 9.435 16.96C9.90262 17.5447 10.4958 18.0167 11.1707 18.341C11.8455 18.6653 12.5846 18.8336 13.3333 18.8334H17.5C17.721 18.8334 17.933 18.7456 18.0893 18.5893C18.2455 18.433 18.3333 18.221 18.3333 18V13.8334C18.3331 13.0844 18.1643 12.3451 17.8395 11.6702C17.5146 10.9954 17.042 10.4023 16.4567 9.93503C16.5907 9.32113 16.6611 8.69504 16.6667 8.0667C17.6791 8.65124 18.5201 9.49175 19.1051 10.5039C19.6902 11.5161 19.9988 12.6643 20 13.8334ZM14.9808 8.54253C15.0588 7.4689 14.9047 6.39107 14.529 5.38228C14.1534 4.37349 13.565 3.45738 12.8038 2.69621C12.0426 1.93504 11.1265 1.34664 10.1178 0.970994C9.10896 0.595348 8.03113 0.441258 6.9575 0.519196C5.05493 0.736473 3.29744 1.64184 2.01598 3.06482C0.734522 4.48779 0.0175226 6.33017 0 8.24503L0 12.445C0 14.555 1.25583 15.5 2.5 15.5H7.25C9.16567 15.4835 11.0092 14.767 12.4332 13.4855C13.8571 12.2039 14.7633 10.4459 14.9808 8.54253ZM11.625 3.87586C12.2166 4.46878 12.674 5.18186 12.9661 5.96685C13.2582 6.75184 13.3784 7.59043 13.3183 8.42586C13.1405 9.91226 12.4254 11.2824 11.3078 12.2783C10.1901 13.2742 8.74699 13.8273 7.25 13.8334H2.5C1.72667 13.8334 1.66667 12.7709 1.66667 12.445V8.24503C1.67361 6.74868 2.22721 5.30645 3.22326 4.18976C4.21932 3.07307 5.58916 2.35892 7.075 2.1817C7.21333 2.1717 7.35167 2.1667 7.49 2.1667C8.25772 2.16598 9.01807 2.31656 9.72757 2.60983C10.4371 2.90309 11.0818 3.3333 11.625 3.87586Z" fill="url(#paint0_linear_224_1740)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_224_1740" x1="10" y1="0.499512" x2="10" y2="20.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_224_1740">
|
||||
<rect width="20" height="20" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
9
public/img/copy.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/img/customer-service.jpg
Normal file
|
After Width: | Height: | Size: 71 KiB |
14
public/img/database-management.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_224_1749)">
|
||||
<path d="M18.9133 16.445L18.1017 15.9767C18.24 15.5625 18.3333 15.1275 18.3333 14.6667C18.3333 14.2058 18.2408 13.7708 18.1017 13.3567L18.9133 12.8883C19.3117 12.6583 19.4483 12.1483 19.2183 11.75C18.9883 11.3517 18.4792 11.2142 18.08 11.445L17.2692 11.9133C16.6817 11.2517 15.8992 10.7675 15 10.585V9.6675C15 9.2075 14.6267 8.83417 14.1667 8.83417C13.7067 8.83417 13.3333 9.2075 13.3333 9.6675V10.585C12.4342 10.7683 11.6517 11.2525 11.0642 11.9133L10.2533 11.445C9.85333 11.2142 9.345 11.3525 9.115 11.75C8.885 12.1492 9.02167 12.6583 9.42 12.8883L10.2317 13.3567C10.0933 13.7708 10 14.2058 10 14.6667C10 15.1275 10.0925 15.5625 10.2317 15.9767L9.42 16.445C9.02167 16.675 8.885 17.185 9.115 17.5833C9.34667 17.985 9.86167 18.1158 10.2533 17.8883L11.0642 17.42C11.6517 18.0817 12.4342 18.5658 13.3333 18.7483V19.6658C13.3333 20.1267 13.7067 20.4992 14.1667 20.4992C14.6267 20.4992 15 20.1267 15 19.6658V18.7483C15.8992 18.565 16.6817 18.0808 17.2692 17.42L18.08 17.8883C18.4717 18.1158 18.9867 17.985 19.2183 17.5833C19.4483 17.1842 19.3117 16.675 18.9133 16.445ZM14.1667 17.1667C12.7883 17.1667 11.6667 16.045 11.6667 14.6667C11.6667 13.2883 12.7883 12.1667 14.1667 12.1667C15.545 12.1667 16.6667 13.2883 16.6667 14.6667C16.6667 16.045 15.545 17.1667 14.1667 17.1667ZM8.26167 18.7825C7.88083 18.815 7.48667 18.8333 7.08333 18.8333C3.57833 18.8333 1.66667 17.6142 1.66667 16.9883V15.0483C2.855 15.7867 4.59583 16.2633 6.64083 16.3267H6.66667C7.115 16.3267 7.485 15.97 7.49917 15.5183C7.51333 15.0583 7.15167 14.6742 6.69167 14.66C3.57833 14.565 1.66583 13.3867 1.66583 12.5833V10.8817C2.85417 11.62 4.595 12.0967 6.64 12.16H6.66583C7.11417 12.16 7.48417 11.8033 7.49833 11.3517C7.5125 10.8917 7.15083 10.5075 6.69083 10.4933C3.5775 10.3975 1.665 9.21917 1.665 8.41583V6.715C2.94417 7.51 4.86083 7.99917 7.08167 7.99917C9.3025 7.99917 11.2192 7.50917 12.4983 6.715V6.74917C12.4983 7.20917 12.8717 7.5825 13.3317 7.5825C13.7917 7.5825 14.165 7.20917 14.165 6.74917V4.24917C14.1667 2.1125 11.1217 0.5 7.08333 0.5C3.045 0.5 0 2.1125 0 4.25V16.9883C0 19.2942 3.56333 20.5 7.08333 20.5C7.525 20.5 7.97 20.4817 8.405 20.4433C8.86333 20.4033 9.20333 20 9.16333 19.5417C9.12333 19.0825 8.72167 18.7333 8.26083 18.7833L8.26167 18.7825ZM7.08333 2.16667C10.39 2.16667 12.5 3.4 12.5 4.25C12.5 5.1 10.39 6.33333 7.08333 6.33333C3.77667 6.33333 1.66667 5.1 1.66667 4.25C1.66667 3.4 3.77667 2.16667 7.08333 2.16667Z" fill="url(#paint0_linear_224_1749)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_224_1749" x1="9.66501" y1="0.5" x2="9.66501" y2="20.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_224_1749">
|
||||
<rect width="20" height="20" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/img/details-pic-1.png
Normal file
|
After Width: | Height: | Size: 453 KiB |
BIN
public/img/details-pic-2.png
Normal file
|
After Width: | Height: | Size: 546 KiB |
BIN
public/img/details-pic-3.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
public/img/details-pic-4.png
Normal file
|
After Width: | Height: | Size: 661 KiB |
BIN
public/img/details-pic-5.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
9
public/img/edit.svg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/img/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 993 B |
BIN
public/img/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/img/features-pic-1.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
public/img/features-pic-2.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
public/img/features-pic-3.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
public/img/features-pic-4.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
public/img/features-pic.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
14
public/img/floor.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_215_750)">
|
||||
<path d="M1.0325 7.97902L8.15833 12.0182C8.72667 12.3407 9.36333 12.5015 10 12.5015C10.6367 12.5015 11.2742 12.3407 11.8425 12.0182L18.9675 7.97819C19.6142 7.61152 20 6.96569 20 6.24902C20 5.53236 19.6142 4.88652 18.9675 4.51986L11.8425 0.481523C10.7075 -0.16181 9.29417 -0.16181 8.15833 0.481523L1.0325 4.52069C0.385833 4.88736 0 5.53319 0 6.24986C0 6.96652 0.385833 7.61236 1.0325 7.97902ZM11.0208 10.5682C10.4008 10.9199 9.59917 10.9199 8.98 10.5682L6.47083 9.14569L11.0483 6.56652L14.5742 8.55319L11.02 10.5682H11.0208ZM18.3333 6.24986C18.3333 6.31402 18.3092 6.43652 18.145 6.52819L16.2667 7.59319L12.7458 5.60986L15.135 4.26402L18.1458 5.97152C18.3092 6.06402 18.3333 6.18569 18.3333 6.24986ZM8.98 1.93152C9.29 1.75569 9.645 1.66819 10 1.66819C10.355 1.66819 10.7108 1.75569 11.0208 1.93152L13.4425 3.30486L8.815 5.91236L5.37667 3.97486L8.98083 1.93152H8.98ZM1.85417 5.97152L3.68417 4.93402L7.1175 6.86902L4.77917 8.18652L1.855 6.52819C1.69167 6.43569 1.6675 6.31402 1.6675 6.24986C1.6675 6.18569 1.69167 6.06319 1.855 5.97152H1.85417ZM19.8858 10.1349C20.1175 10.5324 19.9842 11.0424 19.5875 11.2749L11.8525 15.8024C11.2783 16.1282 10.6375 16.2907 9.9975 16.2907C9.3575 16.2907 8.725 16.1299 8.15833 15.8082L0.425 11.4599C0.0241667 11.234 -0.118333 10.7265 0.1075 10.3249C0.333333 9.92319 0.84 9.78069 1.2425 10.0074L8.97833 14.3565C9.6 14.709 10.4017 14.709 11.0217 14.3574L18.7475 9.83569C19.1425 9.60236 19.6542 9.73652 19.8875 10.1332L19.8858 10.1349ZM19.8858 13.8465C20.1175 14.244 19.9842 14.754 19.5875 14.9865L11.8525 19.514C11.2783 19.8399 10.6375 20.0024 9.9975 20.0024C9.3575 20.0024 8.725 19.8415 8.15833 19.519L0.425 15.1707C0.0241667 14.9449 -0.118333 14.4374 0.1075 14.0357C0.333333 13.6349 0.84 13.4915 1.2425 13.7182L8.97833 18.0674C9.6 18.4199 10.4017 18.4199 11.0217 18.0682L18.7475 13.5465C19.1425 13.3149 19.6542 13.4465 19.8875 13.8449L19.8858 13.8465Z" fill="url(#paint0_linear_215_750)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_215_750" x1="10" y1="-0.000976563" x2="10" y2="20.0024" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_215_750">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/img/google-analytics.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
13
public/img/google.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1550_12981)">
|
||||
<path d="M22.4905 11.926C22.4905 11.0874 22.4224 10.4754 22.2752 9.84082H12.6992V13.6258H18.3201C18.2068 14.5664 17.5948 15.983 16.2349 16.9349L16.2159 17.0616L19.2436 19.4071L19.4534 19.4281C21.3798 17.6489 22.4905 15.0311 22.4905 11.926Z" fill="#4285F4"/>
|
||||
<path d="M12.6992 21.8983C15.453 21.8983 17.7648 20.9917 19.4534 19.4279L16.2349 16.9347C15.3737 17.5353 14.2177 17.9546 12.6992 17.9546C10.0021 17.9546 7.71297 16.1754 6.89695 13.7163L6.77734 13.7265L3.62906 16.1629L3.58789 16.2774C5.26508 19.6091 8.71016 21.8983 12.6992 21.8983Z" fill="#34A853"/>
|
||||
<path d="M6.89695 13.7164C6.68164 13.0818 6.55703 12.4018 6.55703 11.6993C6.55703 10.9966 6.68164 10.3167 6.88562 9.68207L6.87992 9.54691L3.69219 7.07129L3.58789 7.1209C2.89664 8.50348 2.5 10.0561 2.5 11.6993C2.5 13.3425 2.89664 14.895 3.58789 16.2775L6.89695 13.7164Z" fill="#FBBC05"/>
|
||||
<path d="M12.6992 5.44367C14.6144 5.44367 15.9062 6.27094 16.6429 6.96227L19.5213 4.1518C17.7535 2.50859 15.453 1.5 12.6992 1.5C8.71016 1.5 5.26508 3.78914 3.58789 7.12086L6.88562 9.68203C7.71297 7.22289 10.0021 5.44367 12.6992 5.44367Z" fill="#EB4335"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1550_12981">
|
||||
<rect width="20" height="20.4688" fill="white" transform="translate(2.5 1.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |