Spaces:
Running
Running
Thang6822 commited on
Commit ·
7c78a60
0
Parent(s):
Deploy to Hugging Face with LFS
Browse files- .gitattributes +3 -0
- .gitignore +6 -0
- Kronos-master/.gitignore +76 -0
- Kronos-master/LICENSE +21 -0
- Kronos-master/README.md +337 -0
- Kronos-master/model/__init__.py +17 -0
- Kronos-master/model/kronos.py +664 -0
- Kronos-master/model/module.py +570 -0
- Kronos-master/requirements.txt +10 -0
- README.md +91 -0
- SOURCES_AND_SYMBOLS.md +242 -0
- app.py +7 -0
- backend/launcher.py +71 -0
- backend/main.py +0 -0
- frontend/ai_analysis_bg.png +3 -0
- frontend/index.html +2217 -0
- requirements.txt +15 -0
- run.bat +73 -0
.gitattributes
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.db filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
.DS_Store
|
| 6 |
+
*.db
|
Kronos-master/.gitignore
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
MANIFEST
|
| 23 |
+
|
| 24 |
+
# Jupyter Notebook
|
| 25 |
+
.ipynb_checkpoints
|
| 26 |
+
|
| 27 |
+
# PyCharm
|
| 28 |
+
.idea/
|
| 29 |
+
|
| 30 |
+
# VS Code
|
| 31 |
+
.vscode/
|
| 32 |
+
|
| 33 |
+
# macOS
|
| 34 |
+
.DS_Store
|
| 35 |
+
.AppleDouble
|
| 36 |
+
.LSOverride
|
| 37 |
+
|
| 38 |
+
# Windows
|
| 39 |
+
Thumbs.db
|
| 40 |
+
ehthumbs.db
|
| 41 |
+
Desktop.ini
|
| 42 |
+
|
| 43 |
+
# Linux
|
| 44 |
+
*~
|
| 45 |
+
|
| 46 |
+
# Data files (large files)
|
| 47 |
+
*.feather
|
| 48 |
+
*.parquet
|
| 49 |
+
*.h5
|
| 50 |
+
*.hdf5
|
| 51 |
+
|
| 52 |
+
# Model files (large files)
|
| 53 |
+
*.pth
|
| 54 |
+
*.pt
|
| 55 |
+
*.ckpt
|
| 56 |
+
*.bin
|
| 57 |
+
|
| 58 |
+
# Logs
|
| 59 |
+
*.log
|
| 60 |
+
logs/
|
| 61 |
+
|
| 62 |
+
# Environment
|
| 63 |
+
.env
|
| 64 |
+
.venv
|
| 65 |
+
env/
|
| 66 |
+
venv/
|
| 67 |
+
ENV/
|
| 68 |
+
env.bak/
|
| 69 |
+
venv.bak/
|
| 70 |
+
|
| 71 |
+
# Temporary files
|
| 72 |
+
*.tmp
|
| 73 |
+
*.temp
|
| 74 |
+
temp/
|
| 75 |
+
tmp/
|
| 76 |
+
.python-version
|
Kronos-master/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 ShiYu
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Kronos-master/README.md
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
<h2><b>Kronos: A Foundation Model for the Language of Financial Markets </b></h2>
|
| 3 |
+
</div>
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
<div align="center">
|
| 7 |
+
|
| 8 |
+
</a>
|
| 9 |
+
<a href="https://huggingface.co/NeoQuasar">
|
| 10 |
+
<img src="https://img.shields.io/badge/🤗-Hugging_Face-yellow" alt="Hugging Face">
|
| 11 |
+
</a>
|
| 12 |
+
<a href="https://shiyu-coder.github.io/Kronos-demo/"> <img src="https://img.shields.io/badge/🚀-Live_Demo-brightgreen" alt="Live Demo"> </a>
|
| 13 |
+
<a href="https://github.com/shiyu-coder/Kronos/graphs/commit-activity">
|
| 14 |
+
<img src="https://img.shields.io/github/last-commit/shiyu-coder/Kronos?color=blue" alt="Last Commit">
|
| 15 |
+
</a>
|
| 16 |
+
<a href="https://github.com/shiyu-coder/Kronos/stargazers">
|
| 17 |
+
<img src="https://img.shields.io/github/stars/shiyu-coder/Kronos?color=lightblue" alt="GitHub Stars">
|
| 18 |
+
</a>
|
| 19 |
+
<a href="https://github.com/shiyu-coder/Kronos/network/members">
|
| 20 |
+
<img src="https://img.shields.io/github/forks/shiyu-coder/Kronos?color=yellow" alt="GitHub Forks">
|
| 21 |
+
</a>
|
| 22 |
+
<a href="./LICENSE">
|
| 23 |
+
<img src="https://img.shields.io/github/license/shiyu-coder/Kronos?color=green" alt="License">
|
| 24 |
+
</a>
|
| 25 |
+
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div align="center">
|
| 29 |
+
<!-- Keep these links. Translations will automatically update with the README. -->
|
| 30 |
+
<a href="https://zdoc.app/de/shiyu-coder/Kronos">Deutsch</a> |
|
| 31 |
+
<a href="https://zdoc.app/es/shiyu-coder/Kronos">Español</a> |
|
| 32 |
+
<a href="https://zdoc.app/fr/shiyu-coder/Kronos">Français</a> |
|
| 33 |
+
<a href="https://zdoc.app/ja/shiyu-coder/Kronos">日本語</a> |
|
| 34 |
+
<a href="https://zdoc.app/ko/shiyu-coder/Kronos">한국어</a> |
|
| 35 |
+
<a href="https://zdoc.app/pt/shiyu-coder/Kronos">Português</a> |
|
| 36 |
+
<a href="https://zdoc.app/ru/shiyu-coder/Kronos">Русский</a> |
|
| 37 |
+
<a href="https://zdoc.app/zh/shiyu-coder/Kronos">中文</a>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<p align="center">
|
| 41 |
+
|
| 42 |
+
<img src="./figures/logo.png" width="100">
|
| 43 |
+
|
| 44 |
+
</p>
|
| 45 |
+
|
| 46 |
+
> Kronos is the **first open-source foundation model** for financial candlesticks (K-lines),
|
| 47 |
+
> trained on data from over **45 global exchanges**.
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
## 📰 News
|
| 53 |
+
* 🚩 **[2025.11.10]** Kronos has been accpeted by AAAI 2026.
|
| 54 |
+
* 🚩 **[2025.08.17]** We have released the scripts for fine-tuning! Check them out to adapt Kronos to your own tasks.
|
| 55 |
+
* 🚩 **[2025.08.02]** Our paper is now available on [arXiv](https://arxiv.org/abs/2508.02739)!
|
| 56 |
+
|
| 57 |
+
<p align="center">
|
| 58 |
+
|
| 59 |
+
## 📜 Introduction
|
| 60 |
+
|
| 61 |
+
**Kronos** is a family of decoder-only foundation models, pre-trained specifically for the "language" of financial markets—K-line sequences. Unlike general-purpose TSFMs, Kronos is designed to handle the unique, high-noise characteristics of financial data. It leverages a novel two-stage framework:
|
| 62 |
+
1. A specialized tokenizer first quantizes continuous, multi-dimensional K-line data (OHLCV) into **hierarchical discrete tokens**.
|
| 63 |
+
2. A large, autoregressive Transformer is then pre-trained on these tokens, enabling it to serve as a unified model for diverse quantitative tasks.
|
| 64 |
+
|
| 65 |
+
<p align="center">
|
| 66 |
+
<img src="figures/overview.png" alt="" align="center" width="700px" />
|
| 67 |
+
</p>
|
| 68 |
+
|
| 69 |
+
## ✨ Live Demo
|
| 70 |
+
We have set up a live demo to visualize Kronos's forecasting results. The webpage showcases a forecast for the **BTC/USDT** trading pair over the next 24 hours.
|
| 71 |
+
|
| 72 |
+
**👉 [Access the Live Demo Here](https://shiyu-coder.github.io/Kronos-demo/)**
|
| 73 |
+
|
| 74 |
+
## 📦 Model Zoo
|
| 75 |
+
We release a family of pre-trained models with varying capacities to suit different computational and application needs. All models are readily accessible from the Hugging Face Hub.
|
| 76 |
+
|
| 77 |
+
| Model | Tokenizer | Context length | Params | Open-source |
|
| 78 |
+
|--------------|---------------------------------------------------------------------------------| -------------- | ------ |---------------------------------------------------------------------------|
|
| 79 |
+
| Kronos-mini | [Kronos-Tokenizer-2k](https://huggingface.co/NeoQuasar/Kronos-Tokenizer-2k) | 2048 | 4.1M | ✅ [NeoQuasar/Kronos-mini](https://huggingface.co/NeoQuasar/Kronos-mini) |
|
| 80 |
+
| Kronos-small | [Kronos-Tokenizer-base](https://huggingface.co/NeoQuasar/Kronos-Tokenizer-base) | 512 | 24.7M | ✅ [NeoQuasar/Kronos-small](https://huggingface.co/NeoQuasar/Kronos-small) |
|
| 81 |
+
| Kronos-base | [Kronos-Tokenizer-base](https://huggingface.co/NeoQuasar/Kronos-Tokenizer-base) | 512 | 102.3M | ✅ [NeoQuasar/Kronos-base](https://huggingface.co/NeoQuasar/Kronos-base) |
|
| 82 |
+
| Kronos-large | [Kronos-Tokenizer-base](https://huggingface.co/NeoQuasar/Kronos-Tokenizer-base) | 512 | 499.2M | ❌ |
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
## 🚀 Getting Started
|
| 86 |
+
|
| 87 |
+
### Installation
|
| 88 |
+
|
| 89 |
+
1. Install Python 3.10+, and then install the dependencies:
|
| 90 |
+
|
| 91 |
+
```shell
|
| 92 |
+
pip install -r requirements.txt
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### 📈 Making Forecasts
|
| 96 |
+
|
| 97 |
+
Forecasting with Kronos is straightforward using the `KronosPredictor` class. It handles data preprocessing, normalization, prediction, and inverse normalization, allowing you to get from raw data to forecasts in just a few lines of code.
|
| 98 |
+
|
| 99 |
+
**Important Note**: The `max_context` for `Kronos-small` and `Kronos-base` is **512**. This is the maximum sequence length the model can process. For optimal performance, it is recommended that your input data length (i.e., `lookback`) does not exceed this limit. The `KronosPredictor` will automatically handle truncation for longer contexts.
|
| 100 |
+
|
| 101 |
+
Here is a step-by-step guide to making your first forecast.
|
| 102 |
+
|
| 103 |
+
#### 1. Load the Tokenizer and Model
|
| 104 |
+
|
| 105 |
+
First, load a pre-trained Kronos model and its corresponding tokenizer from the Hugging Face Hub.
|
| 106 |
+
|
| 107 |
+
```python
|
| 108 |
+
from model import Kronos, KronosTokenizer, KronosPredictor
|
| 109 |
+
|
| 110 |
+
# Load from Hugging Face Hub
|
| 111 |
+
tokenizer = KronosTokenizer.from_pretrained("NeoQuasar/Kronos-Tokenizer-base")
|
| 112 |
+
model = Kronos.from_pretrained("NeoQuasar/Kronos-small")
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
#### 2. Instantiate the Predictor
|
| 116 |
+
|
| 117 |
+
Create an instance of `KronosPredictor`, passing the model, tokenizer, and desired device.
|
| 118 |
+
|
| 119 |
+
```python
|
| 120 |
+
# Initialize the predictor
|
| 121 |
+
predictor = KronosPredictor(model, tokenizer, max_context=512)
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
#### 3. Prepare Input Data
|
| 125 |
+
|
| 126 |
+
The `predict` method requires three main inputs:
|
| 127 |
+
- `df`: A pandas DataFrame containing the historical K-line data. It must include columns `['open', 'high', 'low', 'close']`. `volume` and `amount` are optional.
|
| 128 |
+
- `x_timestamp`: A pandas Series of timestamps corresponding to the historical data in `df`.
|
| 129 |
+
- `y_timestamp`: A pandas Series of timestamps for the future periods you want to predict.
|
| 130 |
+
|
| 131 |
+
```python
|
| 132 |
+
import pandas as pd
|
| 133 |
+
|
| 134 |
+
# Load your data
|
| 135 |
+
df = pd.read_csv("./data/XSHG_5min_600977.csv")
|
| 136 |
+
df['timestamps'] = pd.to_datetime(df['timestamps'])
|
| 137 |
+
|
| 138 |
+
# Define context window and prediction length
|
| 139 |
+
lookback = 400
|
| 140 |
+
pred_len = 120
|
| 141 |
+
|
| 142 |
+
# Prepare inputs for the predictor
|
| 143 |
+
x_df = df.loc[:lookback-1, ['open', 'high', 'low', 'close', 'volume', 'amount']]
|
| 144 |
+
x_timestamp = df.loc[:lookback-1, 'timestamps']
|
| 145 |
+
y_timestamp = df.loc[lookback:lookback+pred_len-1, 'timestamps']
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
#### 4. Generate Forecasts
|
| 149 |
+
|
| 150 |
+
Call the `predict` method to generate forecasts. You can control the sampling process with parameters like `T`, `top_p`, and `sample_count` for probabilistic forecasting.
|
| 151 |
+
|
| 152 |
+
```python
|
| 153 |
+
# Generate predictions
|
| 154 |
+
pred_df = predictor.predict(
|
| 155 |
+
df=x_df,
|
| 156 |
+
x_timestamp=x_timestamp,
|
| 157 |
+
y_timestamp=y_timestamp,
|
| 158 |
+
pred_len=pred_len,
|
| 159 |
+
T=1.0, # Temperature for sampling
|
| 160 |
+
top_p=0.9, # Nucleus sampling probability
|
| 161 |
+
sample_count=1 # Number of forecast paths to generate and average
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
print("Forecasted Data Head:")
|
| 165 |
+
print(pred_df.head())
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
The `predict` method returns a pandas DataFrame containing the forecasted values for `open`, `high`, `low`, `close`, `volume`, and `amount`, indexed by the `y_timestamp` you provided.
|
| 169 |
+
|
| 170 |
+
For efficient processing of multiple time series, Kronos provides a `predict_batch` method that enables parallel prediction on multiple datasets simultaneously. This is particularly useful when you need to forecast multiple assets or time periods at once.
|
| 171 |
+
|
| 172 |
+
```python
|
| 173 |
+
# Prepare multiple datasets for batch prediction
|
| 174 |
+
df_list = [df1, df2, df3] # List of DataFrames
|
| 175 |
+
x_timestamp_list = [x_ts1, x_ts2, x_ts3] # List of historical timestamps
|
| 176 |
+
y_timestamp_list = [y_ts1, y_ts2, y_ts3] # List of future timestamps
|
| 177 |
+
|
| 178 |
+
# Generate batch predictions
|
| 179 |
+
pred_df_list = predictor.predict_batch(
|
| 180 |
+
df_list=df_list,
|
| 181 |
+
x_timestamp_list=x_timestamp_list,
|
| 182 |
+
y_timestamp_list=y_timestamp_list,
|
| 183 |
+
pred_len=pred_len,
|
| 184 |
+
T=1.0,
|
| 185 |
+
top_p=0.9,
|
| 186 |
+
sample_count=1,
|
| 187 |
+
verbose=True
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# pred_df_list contains prediction results in the same order as input
|
| 191 |
+
for i, pred_df in enumerate(pred_df_list):
|
| 192 |
+
print(f"Predictions for series {i}:")
|
| 193 |
+
print(pred_df.head())
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
**Important Requirements for Batch Prediction:**
|
| 197 |
+
- All series must have the same historical length (lookback window)
|
| 198 |
+
- All series must have the same prediction length (`pred_len`)
|
| 199 |
+
- Each DataFrame must contain the required columns: `['open', 'high', 'low', 'close']`
|
| 200 |
+
- `volume` and `amount` columns are optional and will be filled with zeros if missing
|
| 201 |
+
|
| 202 |
+
The `predict_batch` method leverages GPU parallelism for efficient processing and automatically handles normalization and denormalization for each series independently.
|
| 203 |
+
|
| 204 |
+
#### 5. Example and Visualization
|
| 205 |
+
|
| 206 |
+
For a complete, runnable script that includes data loading, prediction, and plotting, please see [`examples/prediction_example.py`](examples/prediction_example.py).
|
| 207 |
+
|
| 208 |
+
Running this script will generate a plot comparing the ground truth data against the model's forecast, similar to the one shown below:
|
| 209 |
+
|
| 210 |
+
<p align="center">
|
| 211 |
+
<img src="figures/prediction_example.png" alt="Forecast Example" align="center" width="600px" />
|
| 212 |
+
</p>
|
| 213 |
+
|
| 214 |
+
Additionally, we provide a script that makes predictions without Volume and Amount data, which can be found in [`examples/prediction_wo_vol_example.py`](examples/prediction_wo_vol_example.py).
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
## 🔧 Finetuning on Your Own Data (A-Share Market Example)
|
| 218 |
+
|
| 219 |
+
We provide a complete pipeline for finetuning Kronos on your own datasets. As an example, we demonstrate how to use [Qlib](https://github.com/microsoft/qlib) to prepare data from the Chinese A-share market and conduct a simple backtest.
|
| 220 |
+
|
| 221 |
+
> **Disclaimer:** This pipeline is intended as a demonstration to illustrate the finetuning process. It is a simplified example and not a production-ready quantitative trading system. A robust quantitative strategy requires more sophisticated techniques, such as portfolio optimization and risk factor neutralization, to achieve stable alpha.
|
| 222 |
+
|
| 223 |
+
The finetuning process is divided into four main steps:
|
| 224 |
+
|
| 225 |
+
1. **Configuration**: Set up paths and hyperparameters.
|
| 226 |
+
2. **Data Preparation**: Process and split your data using Qlib.
|
| 227 |
+
3. **Model Finetuning**: Finetune the Tokenizer and the Predictor models.
|
| 228 |
+
4. **Backtesting**: Evaluate the finetuned model's performance.
|
| 229 |
+
|
| 230 |
+
### Prerequisites
|
| 231 |
+
|
| 232 |
+
1. First, ensure you have all dependencies from `requirements.txt` installed.
|
| 233 |
+
2. This pipeline relies on `qlib`. Please install it:
|
| 234 |
+
```shell
|
| 235 |
+
pip install pyqlib
|
| 236 |
+
```
|
| 237 |
+
3. You will need to prepare your Qlib data. Follow the [official Qlib guide](https://github.com/microsoft/qlib) to download and set up your data locally. The example scripts assume you are using daily frequency data.
|
| 238 |
+
|
| 239 |
+
### Step 1: Configure Your Experiment
|
| 240 |
+
|
| 241 |
+
All settings for data, training, and model paths are centralized in `finetune/config.py`. Before running any scripts, please **modify the following paths** according to your environment:
|
| 242 |
+
|
| 243 |
+
* `qlib_data_path`: Path to your local Qlib data directory.
|
| 244 |
+
* `dataset_path`: Directory where the processed train/validation/test pickle files will be saved.
|
| 245 |
+
* `save_path`: Base directory for saving model checkpoints.
|
| 246 |
+
* `backtest_result_path`: Directory for saving backtesting results.
|
| 247 |
+
* `pretrained_tokenizer_path` and `pretrained_predictor_path`: Paths to the pre-trained models you want to start from (can be local paths or Hugging Face model names).
|
| 248 |
+
|
| 249 |
+
You can also adjust other parameters like `instrument`, `train_time_range`, `epochs`, and `batch_size` to fit your specific task. If you don't use [Comet.ml](https://www.comet.com/), set `use_comet = False`.
|
| 250 |
+
|
| 251 |
+
### Step 2: Prepare the Dataset
|
| 252 |
+
|
| 253 |
+
Run the data preprocessing script. This script will load raw market data from your Qlib directory, process it, split it into training, validation, and test sets, and save them as pickle files.
|
| 254 |
+
|
| 255 |
+
```shell
|
| 256 |
+
python finetune/qlib_data_preprocess.py
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
After running, you will find `train_data.pkl`, `val_data.pkl`, and `test_data.pkl` in the directory specified by `dataset_path` in your config.
|
| 260 |
+
|
| 261 |
+
### Step 3: Run the Finetuning
|
| 262 |
+
|
| 263 |
+
The finetuning process consists of two stages: finetuning the tokenizer and then the predictor. Both training scripts are designed for multi-GPU training using `torchrun`.
|
| 264 |
+
|
| 265 |
+
#### 3.1 Finetune the Tokenizer
|
| 266 |
+
|
| 267 |
+
This step adjusts the tokenizer to the data distribution of your specific domain.
|
| 268 |
+
|
| 269 |
+
```shell
|
| 270 |
+
# Replace NUM_GPUS with the number of GPUs you want to use (e.g., 2)
|
| 271 |
+
torchrun --standalone --nproc_per_node=NUM_GPUS finetune/train_tokenizer.py
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
The best tokenizer checkpoint will be saved to the path configured in `config.py` (derived from `save_path` and `tokenizer_save_folder_name`).
|
| 275 |
+
|
| 276 |
+
#### 3.2 Finetune the Predictor
|
| 277 |
+
|
| 278 |
+
This step finetunes the main Kronos model for the forecasting task.
|
| 279 |
+
|
| 280 |
+
```shell
|
| 281 |
+
# Replace NUM_GPUS with the number of GPUs you want to use (e.g., 2)
|
| 282 |
+
torchrun --standalone --nproc_per_node=NUM_GPUS finetune/train_predictor.py
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
The best predictor checkpoint will be saved to the path configured in `config.py`.
|
| 286 |
+
|
| 287 |
+
### Step 4: Evaluate with Backtesting
|
| 288 |
+
|
| 289 |
+
Finally, run the backtesting script to evaluate your finetuned model. This script loads the models, performs inference on the test set, generates prediction signals (e.g., forecasted price change), and runs a simple top-K strategy backtest.
|
| 290 |
+
|
| 291 |
+
```shell
|
| 292 |
+
# Specify the GPU for inference
|
| 293 |
+
python finetune/qlib_test.py --device cuda:0
|
| 294 |
+
```
|
| 295 |
+
|
| 296 |
+
The script will output a detailed performance analysis in your console and generate a plot showing the cumulative return curves of your strategy against the benchmark, similar to the one below:
|
| 297 |
+
|
| 298 |
+
<p align="center">
|
| 299 |
+
<img src="figures/backtest_result_example.png" alt="Backtest Example" align="center" width="700px" />
|
| 300 |
+
</p>
|
| 301 |
+
|
| 302 |
+
### 💡 From Demo to Production: Important Considerations
|
| 303 |
+
|
| 304 |
+
* **Raw Signals vs. Pure Alpha**: The signals generated by the model in this demo are raw predictions. In a real-world quantitative workflow, these signals would typically be fed into a portfolio optimization model. This model would apply constraints to neutralize exposure to common risk factors (e.g., market beta, style factors like size and value), thereby isolating the **"pure alpha"** and improving the strategy's robustness.
|
| 305 |
+
* **Data Handling**: The provided `QlibDataset` is an example. For different data sources or formats, you will need to adapt the data loading and preprocessing logic.
|
| 306 |
+
* **Strategy and Backtesting Complexity**: The simple top-K strategy used here is a basic starting point. Production-level strategies often incorporate more complex logic for portfolio construction, dynamic position sizing, and risk management (e.g., stop-loss/take-profit rules). Furthermore, a high-fidelity backtest should meticulously model transaction costs, slippage, and market impact to provide a more accurate estimate of real-world performance.
|
| 307 |
+
|
| 308 |
+
> **📝 AI-Generated Comments**: Please note that many of the code comments within the `finetune/` directory were generated by an AI assistant (Gemini 2.5 Pro) for explanatory purposes. While they aim to be helpful, they may contain inaccuracies. We recommend treating the code itself as the definitive source of logic.
|
| 309 |
+
|
| 310 |
+
## 📖 Citation
|
| 311 |
+
|
| 312 |
+
If you use Kronos in your research, we would appreciate a citation to our [paper](https://arxiv.org/abs/2508.02739):
|
| 313 |
+
|
| 314 |
+
```
|
| 315 |
+
@misc{shi2025kronos,
|
| 316 |
+
title={Kronos: A Foundation Model for the Language of Financial Markets},
|
| 317 |
+
author={Yu Shi and Zongliang Fu and Shuo Chen and Bohan Zhao and Wei Xu and Changshui Zhang and Jian Li},
|
| 318 |
+
year={2025},
|
| 319 |
+
eprint={2508.02739},
|
| 320 |
+
archivePrefix={arXiv},
|
| 321 |
+
primaryClass={q-fin.ST},
|
| 322 |
+
url={https://arxiv.org/abs/2508.02739},
|
| 323 |
+
}
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
## 📜 License
|
| 327 |
+
This project is licensed under the [MIT License](./LICENSE).
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
|
Kronos-master/model/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .kronos import KronosTokenizer, Kronos, KronosPredictor
|
| 2 |
+
|
| 3 |
+
model_dict = {
|
| 4 |
+
'kronos_tokenizer': KronosTokenizer,
|
| 5 |
+
'kronos': Kronos,
|
| 6 |
+
'kronos_predictor': KronosPredictor
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_model_class(model_name):
|
| 11 |
+
if model_name in model_dict:
|
| 12 |
+
return model_dict[model_name]
|
| 13 |
+
else:
|
| 14 |
+
print(f"Model {model_name} not found in model_dict")
|
| 15 |
+
raise NotImplementedError
|
| 16 |
+
|
| 17 |
+
|
Kronos-master/model/kronos.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import torch
|
| 4 |
+
from huggingface_hub import PyTorchModelHubMixin
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
from tqdm import trange
|
| 8 |
+
|
| 9 |
+
sys.path.append("../")
|
| 10 |
+
from model.module import *
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class KronosTokenizer(nn.Module, PyTorchModelHubMixin):
|
| 14 |
+
"""
|
| 15 |
+
KronosTokenizer module for tokenizing input data using a hybrid quantization approach.
|
| 16 |
+
|
| 17 |
+
This tokenizer utilizes a combination of encoder and decoder Transformer blocks
|
| 18 |
+
along with the Binary Spherical Quantization (BSQuantizer) to compress and decompress input data.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
d_in (int): Input dimension.
|
| 22 |
+
d_model (int): Model dimension.
|
| 23 |
+
n_heads (int): Number of attention heads.
|
| 24 |
+
ff_dim (int): Feed-forward dimension.
|
| 25 |
+
n_enc_layers (int): Number of encoder layers.
|
| 26 |
+
n_dec_layers (int): Number of decoder layers.
|
| 27 |
+
ffn_dropout_p (float): Dropout probability for feed-forward networks.
|
| 28 |
+
attn_dropout_p (float): Dropout probability for attention mechanisms.
|
| 29 |
+
resid_dropout_p (float): Dropout probability for residual connections.
|
| 30 |
+
s1_bits (int): Number of bits for the pre token in BSQuantizer.
|
| 31 |
+
s2_bits (int): Number of bits for the post token in BSQuantizer.
|
| 32 |
+
beta (float): Beta parameter for BSQuantizer.
|
| 33 |
+
gamma0 (float): Gamma0 parameter for BSQuantizer.
|
| 34 |
+
gamma (float): Gamma parameter for BSQuantizer.
|
| 35 |
+
zeta (float): Zeta parameter for BSQuantizer.
|
| 36 |
+
group_size (int): Group size parameter for BSQuantizer.
|
| 37 |
+
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
def __init__(self, d_in, d_model, n_heads, ff_dim, n_enc_layers, n_dec_layers, ffn_dropout_p, attn_dropout_p, resid_dropout_p, s1_bits, s2_bits, beta, gamma0, gamma, zeta, group_size):
|
| 41 |
+
|
| 42 |
+
super().__init__()
|
| 43 |
+
self.d_in = d_in
|
| 44 |
+
self.d_model = d_model
|
| 45 |
+
self.n_heads = n_heads
|
| 46 |
+
self.ff_dim = ff_dim
|
| 47 |
+
self.enc_layers = n_enc_layers
|
| 48 |
+
self.dec_layers = n_dec_layers
|
| 49 |
+
self.ffn_dropout_p = ffn_dropout_p
|
| 50 |
+
self.attn_dropout_p = attn_dropout_p
|
| 51 |
+
self.resid_dropout_p = resid_dropout_p
|
| 52 |
+
|
| 53 |
+
self.s1_bits = s1_bits
|
| 54 |
+
self.s2_bits = s2_bits
|
| 55 |
+
self.codebook_dim = s1_bits + s2_bits # Total dimension of the codebook after quantization
|
| 56 |
+
self.embed = nn.Linear(self.d_in, self.d_model)
|
| 57 |
+
self.head = nn.Linear(self.d_model, self.d_in)
|
| 58 |
+
|
| 59 |
+
# Encoder Transformer Blocks
|
| 60 |
+
self.encoder = nn.ModuleList([
|
| 61 |
+
TransformerBlock(self.d_model, self.n_heads, self.ff_dim, self.ffn_dropout_p, self.attn_dropout_p, self.resid_dropout_p)
|
| 62 |
+
for _ in range(self.enc_layers - 1)
|
| 63 |
+
])
|
| 64 |
+
# Decoder Transformer Blocks
|
| 65 |
+
self.decoder = nn.ModuleList([
|
| 66 |
+
TransformerBlock(self.d_model, self.n_heads, self.ff_dim, self.ffn_dropout_p, self.attn_dropout_p, self.resid_dropout_p)
|
| 67 |
+
for _ in range(self.dec_layers - 1)
|
| 68 |
+
])
|
| 69 |
+
self.quant_embed = nn.Linear(in_features=self.d_model, out_features=self.codebook_dim) # Linear layer before quantization
|
| 70 |
+
self.post_quant_embed_pre = nn.Linear(in_features=self.s1_bits, out_features=self.d_model) # Linear layer after quantization (pre part - s1 bits)
|
| 71 |
+
self.post_quant_embed = nn.Linear(in_features=self.codebook_dim, out_features=self.d_model) # Linear layer after quantization (full codebook)
|
| 72 |
+
self.tokenizer = BSQuantizer(self.s1_bits, self.s2_bits, beta, gamma0, gamma, zeta, group_size) # BSQuantizer module
|
| 73 |
+
|
| 74 |
+
def forward(self, x):
|
| 75 |
+
"""
|
| 76 |
+
Forward pass of the KronosTokenizer.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
x (torch.Tensor): Input tensor of shape (batch_size, seq_len, d_in).
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
tuple: A tuple containing:
|
| 83 |
+
- tuple: (z_pre, z) - Reconstructed outputs from decoder with s1_bits and full codebook respectively,
|
| 84 |
+
both of shape (batch_size, seq_len, d_in).
|
| 85 |
+
- torch.Tensor: bsq_loss - Loss from the BSQuantizer.
|
| 86 |
+
- torch.Tensor: quantized - Quantized representation from BSQuantizer.
|
| 87 |
+
- torch.Tensor: z_indices - Indices from the BSQuantizer.
|
| 88 |
+
"""
|
| 89 |
+
z = self.embed(x)
|
| 90 |
+
|
| 91 |
+
for layer in self.encoder:
|
| 92 |
+
z = layer(z)
|
| 93 |
+
|
| 94 |
+
z = self.quant_embed(z) # (B, T, codebook)
|
| 95 |
+
|
| 96 |
+
bsq_loss, quantized, z_indices = self.tokenizer(z)
|
| 97 |
+
|
| 98 |
+
quantized_pre = quantized[:, :, :self.s1_bits] # Extract the first part of quantized representation (s1_bits)
|
| 99 |
+
z_pre = self.post_quant_embed_pre(quantized_pre)
|
| 100 |
+
|
| 101 |
+
z = self.post_quant_embed(quantized)
|
| 102 |
+
|
| 103 |
+
# Decoder layers (for pre part - s1 bits)
|
| 104 |
+
for layer in self.decoder:
|
| 105 |
+
z_pre = layer(z_pre)
|
| 106 |
+
z_pre = self.head(z_pre)
|
| 107 |
+
|
| 108 |
+
# Decoder layers (for full codebook)
|
| 109 |
+
for layer in self.decoder:
|
| 110 |
+
z = layer(z)
|
| 111 |
+
z = self.head(z)
|
| 112 |
+
|
| 113 |
+
return (z_pre, z), bsq_loss, quantized, z_indices
|
| 114 |
+
|
| 115 |
+
def indices_to_bits(self, x, half=False):
|
| 116 |
+
"""
|
| 117 |
+
Converts indices to bit representations and scales them.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
x (torch.Tensor): Indices tensor.
|
| 121 |
+
half (bool, optional): Whether to process only half of the codebook dimension. Defaults to False.
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
torch.Tensor: Bit representation tensor.
|
| 125 |
+
"""
|
| 126 |
+
if half:
|
| 127 |
+
x1 = x[0] # Assuming x is a tuple of indices if half is True
|
| 128 |
+
x2 = x[1]
|
| 129 |
+
mask = 2 ** torch.arange(self.codebook_dim//2, device=x1.device, dtype=torch.long) # Create a mask for bit extraction
|
| 130 |
+
x1 = (x1.unsqueeze(-1) & mask) != 0 # Extract bits for the first half
|
| 131 |
+
x2 = (x2.unsqueeze(-1) & mask) != 0 # Extract bits for the second half
|
| 132 |
+
x = torch.cat([x1, x2], dim=-1) # Concatenate the bit representations
|
| 133 |
+
else:
|
| 134 |
+
mask = 2 ** torch.arange(self.codebook_dim, device=x.device, dtype=torch.long) # Create a mask for bit extraction
|
| 135 |
+
x = (x.unsqueeze(-1) & mask) != 0 # Extract bits
|
| 136 |
+
|
| 137 |
+
x = x.float() * 2 - 1 # Convert boolean to bipolar (-1, 1)
|
| 138 |
+
q_scale = 1. / (self.codebook_dim ** 0.5) # Scaling factor
|
| 139 |
+
x = x * q_scale
|
| 140 |
+
return x
|
| 141 |
+
|
| 142 |
+
def encode(self, x, half=False):
|
| 143 |
+
"""
|
| 144 |
+
Encodes the input data into quantized indices.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
x (torch.Tensor): Input tensor of shape (batch_size, seq_len, d_in).
|
| 148 |
+
half (bool, optional): Whether to use half quantization in BSQuantizer. Defaults to False.
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
torch.Tensor: Quantized indices from BSQuantizer.
|
| 152 |
+
"""
|
| 153 |
+
z = self.embed(x)
|
| 154 |
+
for layer in self.encoder:
|
| 155 |
+
z = layer(z)
|
| 156 |
+
z = self.quant_embed(z)
|
| 157 |
+
|
| 158 |
+
bsq_loss, quantized, z_indices = self.tokenizer(z, half=half, collect_metrics=False)
|
| 159 |
+
return z_indices
|
| 160 |
+
|
| 161 |
+
def decode(self, x, half=False):
|
| 162 |
+
"""
|
| 163 |
+
Decodes quantized indices back to the input data space.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
x (torch.Tensor): Quantized indices tensor.
|
| 167 |
+
half (bool, optional): Whether the indices were generated with half quantization. Defaults to False.
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
torch.Tensor: Reconstructed output tensor of shape (batch_size, seq_len, d_in).
|
| 171 |
+
"""
|
| 172 |
+
quantized = self.indices_to_bits(x, half)
|
| 173 |
+
z = self.post_quant_embed(quantized)
|
| 174 |
+
for layer in self.decoder:
|
| 175 |
+
z = layer(z)
|
| 176 |
+
z = self.head(z)
|
| 177 |
+
return z
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class Kronos(nn.Module, PyTorchModelHubMixin):
|
| 181 |
+
"""
|
| 182 |
+
Kronos Model.
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
s1_bits (int): Number of bits for pre tokens.
|
| 186 |
+
s2_bits (int): Number of bits for post tokens.
|
| 187 |
+
n_layers (int): Number of Transformer blocks.
|
| 188 |
+
d_model (int): Dimension of the model's embeddings and hidden states.
|
| 189 |
+
n_heads (int): Number of attention heads in the MultiheadAttention layers.
|
| 190 |
+
ff_dim (int): Dimension of the feedforward network in the Transformer blocks.
|
| 191 |
+
ffn_dropout_p (float): Dropout probability for the feedforward network.
|
| 192 |
+
attn_dropout_p (float): Dropout probability for the attention layers.
|
| 193 |
+
resid_dropout_p (float): Dropout probability for residual connections.
|
| 194 |
+
token_dropout_p (float): Dropout probability for token embeddings.
|
| 195 |
+
learn_te (bool): Whether to use learnable temporal embeddings.
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
def __init__(self, s1_bits, s2_bits, n_layers, d_model, n_heads, ff_dim, ffn_dropout_p, attn_dropout_p, resid_dropout_p, token_dropout_p, learn_te):
|
| 199 |
+
super().__init__()
|
| 200 |
+
self.s1_bits = s1_bits
|
| 201 |
+
self.s2_bits = s2_bits
|
| 202 |
+
self.n_layers = n_layers
|
| 203 |
+
self.d_model = d_model
|
| 204 |
+
self.n_heads = n_heads
|
| 205 |
+
self.learn_te = learn_te
|
| 206 |
+
self.ff_dim = ff_dim
|
| 207 |
+
self.ffn_dropout_p = ffn_dropout_p
|
| 208 |
+
self.attn_dropout_p = attn_dropout_p
|
| 209 |
+
self.resid_dropout_p = resid_dropout_p
|
| 210 |
+
self.token_dropout_p = token_dropout_p
|
| 211 |
+
|
| 212 |
+
self.s1_vocab_size = 2 ** self.s1_bits
|
| 213 |
+
self.token_drop = nn.Dropout(self.token_dropout_p)
|
| 214 |
+
self.embedding = HierarchicalEmbedding(self.s1_bits, self.s2_bits, self.d_model)
|
| 215 |
+
self.time_emb = TemporalEmbedding(self.d_model, self.learn_te)
|
| 216 |
+
self.transformer = nn.ModuleList([
|
| 217 |
+
TransformerBlock(self.d_model, self.n_heads, self.ff_dim, self.ffn_dropout_p, self.attn_dropout_p, self.resid_dropout_p)
|
| 218 |
+
for _ in range(self.n_layers)
|
| 219 |
+
])
|
| 220 |
+
self.norm = RMSNorm(self.d_model)
|
| 221 |
+
self.dep_layer = DependencyAwareLayer(self.d_model)
|
| 222 |
+
self.head = DualHead(self.s1_bits, self.s2_bits, self.d_model)
|
| 223 |
+
self.apply(self._init_weights)
|
| 224 |
+
|
| 225 |
+
def _init_weights(self, module):
|
| 226 |
+
|
| 227 |
+
if isinstance(module, nn.Linear):
|
| 228 |
+
nn.init.xavier_normal_(module.weight)
|
| 229 |
+
if module.bias is not None:
|
| 230 |
+
nn.init.zeros_(module.bias)
|
| 231 |
+
elif isinstance(module, nn.Embedding):
|
| 232 |
+
nn.init.normal_(module.weight, mean=0, std=self.embedding.d_model ** -0.5)
|
| 233 |
+
elif isinstance(module, nn.LayerNorm):
|
| 234 |
+
nn.init.ones_(module.weight)
|
| 235 |
+
nn.init.zeros_(module.bias)
|
| 236 |
+
elif isinstance(module, RMSNorm):
|
| 237 |
+
nn.init.ones_(module.weight)
|
| 238 |
+
|
| 239 |
+
def forward(self, s1_ids, s2_ids, stamp=None, padding_mask=None, use_teacher_forcing=False, s1_targets=None):
|
| 240 |
+
"""
|
| 241 |
+
Args:
|
| 242 |
+
s1_ids (torch.Tensor): Input tensor of s1 token IDs. Shape: [batch_size, seq_len]
|
| 243 |
+
s2_ids (torch.Tensor): Input tensor of s2 token IDs. Shape: [batch_size, seq_len]
|
| 244 |
+
stamp (torch.Tensor, optional): Temporal stamp tensor. Shape: [batch_size, seq_len]. Defaults to None.
|
| 245 |
+
padding_mask (torch.Tensor, optional): Mask for padding tokens. Shape: [batch_size, seq_len]. Defaults to None.
|
| 246 |
+
use_teacher_forcing (bool, optional): Whether to use teacher forcing for s1 decoding. Defaults to False.
|
| 247 |
+
s1_targets (torch.Tensor, optional): Target s1 token IDs for teacher forcing. Shape: [batch_size, seq_len]. Defaults to None.
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
Tuple[torch.Tensor, torch.Tensor]:
|
| 251 |
+
- s1 logits: Logits for s1 token predictions. Shape: [batch_size, seq_len, s1_vocab_size]
|
| 252 |
+
- s2_logits: Logits for s2 token predictions, conditioned on s1. Shape: [batch_size, seq_len, s2_vocab_size]
|
| 253 |
+
"""
|
| 254 |
+
x = self.embedding([s1_ids, s2_ids])
|
| 255 |
+
if stamp is not None:
|
| 256 |
+
time_embedding = self.time_emb(stamp)
|
| 257 |
+
x = x + time_embedding
|
| 258 |
+
x = self.token_drop(x)
|
| 259 |
+
|
| 260 |
+
for layer in self.transformer:
|
| 261 |
+
x = layer(x, key_padding_mask=padding_mask)
|
| 262 |
+
|
| 263 |
+
x = self.norm(x)
|
| 264 |
+
|
| 265 |
+
s1_logits = self.head(x)
|
| 266 |
+
|
| 267 |
+
if use_teacher_forcing:
|
| 268 |
+
sibling_embed = self.embedding.emb_s1(s1_targets)
|
| 269 |
+
else:
|
| 270 |
+
s1_probs = F.softmax(s1_logits.detach(), dim=-1)
|
| 271 |
+
sample_s1_ids = torch.multinomial(s1_probs.view(-1, self.s1_vocab_size), 1).view(s1_ids.shape)
|
| 272 |
+
sibling_embed = self.embedding.emb_s1(sample_s1_ids)
|
| 273 |
+
|
| 274 |
+
x2 = self.dep_layer(x, sibling_embed, key_padding_mask=padding_mask) # Dependency Aware Layer: Condition on s1 embeddings
|
| 275 |
+
s2_logits = self.head.cond_forward(x2)
|
| 276 |
+
return s1_logits, s2_logits
|
| 277 |
+
|
| 278 |
+
def decode_s1(self, s1_ids, s2_ids, stamp=None, padding_mask=None):
|
| 279 |
+
"""
|
| 280 |
+
Decodes only the s1 tokens.
|
| 281 |
+
|
| 282 |
+
This method performs a forward pass to predict only s1 tokens. It returns the s1 logits
|
| 283 |
+
and the context representation from the Transformer, which can be used for subsequent s2 decoding.
|
| 284 |
+
|
| 285 |
+
Args:
|
| 286 |
+
s1_ids (torch.Tensor): Input tensor of s1 token IDs. Shape: [batch_size, seq_len]
|
| 287 |
+
s2_ids (torch.Tensor): Input tensor of s2 token IDs. Shape: [batch_size, seq_len]
|
| 288 |
+
stamp (torch.Tensor, optional): Temporal stamp tensor. Shape: [batch_size, seq_len]. Defaults to None.
|
| 289 |
+
padding_mask (torch.Tensor, optional): Mask for padding tokens. Shape: [batch_size, seq_len]. Defaults to None.
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
Tuple[torch.Tensor, torch.Tensor]:
|
| 293 |
+
- s1 logits: Logits for s1 token predictions. Shape: [batch_size, seq_len, s1_vocab_size]
|
| 294 |
+
- context: Context representation from the Transformer. Shape: [batch_size, seq_len, d_model]
|
| 295 |
+
"""
|
| 296 |
+
x = self.embedding([s1_ids, s2_ids])
|
| 297 |
+
if stamp is not None:
|
| 298 |
+
time_embedding = self.time_emb(stamp)
|
| 299 |
+
x = x + time_embedding
|
| 300 |
+
x = self.token_drop(x)
|
| 301 |
+
|
| 302 |
+
for layer in self.transformer:
|
| 303 |
+
x = layer(x, key_padding_mask=padding_mask)
|
| 304 |
+
|
| 305 |
+
x = self.norm(x)
|
| 306 |
+
|
| 307 |
+
s1_logits = self.head(x)
|
| 308 |
+
return s1_logits, x
|
| 309 |
+
|
| 310 |
+
def decode_s2(self, context, s1_ids, padding_mask=None):
|
| 311 |
+
"""
|
| 312 |
+
Decodes the s2 tokens, conditioned on the context and s1 tokens.
|
| 313 |
+
|
| 314 |
+
This method decodes s2 tokens based on a pre-computed context representation (typically from `decode_s1`)
|
| 315 |
+
and the s1 token IDs. It uses the dependency-aware layer and the conditional s2 head to predict s2 tokens.
|
| 316 |
+
|
| 317 |
+
Args:
|
| 318 |
+
context (torch.Tensor): Context representation from the transformer (output of decode_s1).
|
| 319 |
+
Shape: [batch_size, seq_len, d_model]
|
| 320 |
+
s1_ids (torch.Tensor): Input tensor of s1 token IDs. Shape: [batch_size, seq_len]
|
| 321 |
+
padding_mask (torch.Tensor, optional): Mask for padding tokens. Shape: [batch_size, seq_len]. Defaults to None.
|
| 322 |
+
|
| 323 |
+
Returns:
|
| 324 |
+
torch.Tensor: s2 logits. Shape: [batch_size, seq_len, s2_vocab_size]
|
| 325 |
+
"""
|
| 326 |
+
sibling_embed = self.embedding.emb_s1(s1_ids)
|
| 327 |
+
x2 = self.dep_layer(context, sibling_embed, key_padding_mask=padding_mask)
|
| 328 |
+
return self.head.cond_forward(x2)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def top_k_top_p_filtering(
|
| 332 |
+
logits,
|
| 333 |
+
top_k: int = 0,
|
| 334 |
+
top_p: float = 1.0,
|
| 335 |
+
filter_value: float = -float("Inf"),
|
| 336 |
+
min_tokens_to_keep: int = 1,
|
| 337 |
+
):
|
| 338 |
+
"""Filter a distribution of logits using top-k and/or nucleus (top-p) filtering
|
| 339 |
+
Args:
|
| 340 |
+
logits: logits distribution shape (batch size, vocabulary size)
|
| 341 |
+
if top_k > 0: keep only top k tokens with highest probability (top-k filtering).
|
| 342 |
+
if top_p < 1.0: keep the top tokens with cumulative probability >= top_p (nucleus filtering).
|
| 343 |
+
Nucleus filtering is described in Holtzman et al. (http://arxiv.org/abs/1904.09751)
|
| 344 |
+
Make sure we keep at least min_tokens_to_keep per batch example in the output
|
| 345 |
+
From: https://gist.github.com/thomwolf/1a5a29f6962089e871b94cbd09daf317
|
| 346 |
+
"""
|
| 347 |
+
if top_k > 0:
|
| 348 |
+
top_k = min(max(top_k, min_tokens_to_keep), logits.size(-1)) # Safety check
|
| 349 |
+
# Remove all tokens with a probability less than the last token of the top-k
|
| 350 |
+
indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]
|
| 351 |
+
logits[indices_to_remove] = filter_value
|
| 352 |
+
return logits
|
| 353 |
+
|
| 354 |
+
if top_p < 1.0:
|
| 355 |
+
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
|
| 356 |
+
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
|
| 357 |
+
|
| 358 |
+
# Remove tokens with cumulative probability above the threshold (token with 0 are kept)
|
| 359 |
+
sorted_indices_to_remove = cumulative_probs > top_p
|
| 360 |
+
if min_tokens_to_keep > 1:
|
| 361 |
+
# Keep at least min_tokens_to_keep (set to min_tokens_to_keep-1 because we add the first one below)
|
| 362 |
+
sorted_indices_to_remove[..., :min_tokens_to_keep] = 0
|
| 363 |
+
# Shift the indices to the right to keep also the first token above the threshold
|
| 364 |
+
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
|
| 365 |
+
sorted_indices_to_remove[..., 0] = 0
|
| 366 |
+
|
| 367 |
+
# scatter sorted tensors to original indexing
|
| 368 |
+
indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove)
|
| 369 |
+
logits[indices_to_remove] = filter_value
|
| 370 |
+
return logits
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def sample_from_logits(logits, temperature=1.0, top_k=None, top_p=None, sample_logits=True):
|
| 374 |
+
logits = logits / temperature
|
| 375 |
+
if top_k is not None or top_p is not None:
|
| 376 |
+
if top_k > 0 or top_p < 1.0:
|
| 377 |
+
logits = top_k_top_p_filtering(logits, top_k=top_k, top_p=top_p)
|
| 378 |
+
|
| 379 |
+
probs = F.softmax(logits, dim=-1)
|
| 380 |
+
|
| 381 |
+
if not sample_logits:
|
| 382 |
+
_, x = torch.topk(probs, k=1, dim=-1)
|
| 383 |
+
else:
|
| 384 |
+
x = torch.multinomial(probs, num_samples=1)
|
| 385 |
+
|
| 386 |
+
return x
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def auto_regressive_inference(tokenizer, model, x, x_stamp, y_stamp, max_context, pred_len, clip=5, T=1.0, top_k=0, top_p=0.99, sample_count=5, verbose=False, return_samples=False):
|
| 390 |
+
with torch.no_grad():
|
| 391 |
+
x = torch.clip(x, -clip, clip)
|
| 392 |
+
|
| 393 |
+
device = x.device
|
| 394 |
+
x = x.unsqueeze(1).repeat(1, sample_count, 1, 1).reshape(-1, x.size(1), x.size(2)).to(device)
|
| 395 |
+
x_stamp = x_stamp.unsqueeze(1).repeat(1, sample_count, 1, 1).reshape(-1, x_stamp.size(1), x_stamp.size(2)).to(device)
|
| 396 |
+
y_stamp = y_stamp.unsqueeze(1).repeat(1, sample_count, 1, 1).reshape(-1, y_stamp.size(1), y_stamp.size(2)).to(device)
|
| 397 |
+
|
| 398 |
+
x_token = tokenizer.encode(x, half=True)
|
| 399 |
+
|
| 400 |
+
initial_seq_len = x.size(1)
|
| 401 |
+
batch_size = x_token[0].size(0)
|
| 402 |
+
total_seq_len = initial_seq_len + pred_len
|
| 403 |
+
full_stamp = torch.cat([x_stamp, y_stamp], dim=1)
|
| 404 |
+
|
| 405 |
+
generated_pre = x_token[0].new_empty(batch_size, pred_len)
|
| 406 |
+
generated_post = x_token[1].new_empty(batch_size, pred_len)
|
| 407 |
+
|
| 408 |
+
pre_buffer = x_token[0].new_zeros(batch_size, max_context)
|
| 409 |
+
post_buffer = x_token[1].new_zeros(batch_size, max_context)
|
| 410 |
+
buffer_len = min(initial_seq_len, max_context)
|
| 411 |
+
if buffer_len > 0:
|
| 412 |
+
start_idx = max(0, initial_seq_len - max_context)
|
| 413 |
+
pre_buffer[:, :buffer_len] = x_token[0][:, start_idx:start_idx + buffer_len]
|
| 414 |
+
post_buffer[:, :buffer_len] = x_token[1][:, start_idx:start_idx + buffer_len]
|
| 415 |
+
|
| 416 |
+
if verbose:
|
| 417 |
+
ran = trange
|
| 418 |
+
else:
|
| 419 |
+
ran = range
|
| 420 |
+
for i in ran(pred_len):
|
| 421 |
+
current_seq_len = initial_seq_len + i
|
| 422 |
+
window_len = min(current_seq_len, max_context)
|
| 423 |
+
|
| 424 |
+
if current_seq_len <= max_context:
|
| 425 |
+
input_tokens = [
|
| 426 |
+
pre_buffer[:, :window_len],
|
| 427 |
+
post_buffer[:, :window_len]
|
| 428 |
+
]
|
| 429 |
+
else:
|
| 430 |
+
input_tokens = [pre_buffer, post_buffer]
|
| 431 |
+
|
| 432 |
+
context_end = current_seq_len
|
| 433 |
+
context_start = max(0, context_end - max_context)
|
| 434 |
+
current_stamp = full_stamp[:, context_start:context_end, :].contiguous()
|
| 435 |
+
|
| 436 |
+
s1_logits, context = model.decode_s1(input_tokens[0], input_tokens[1], current_stamp)
|
| 437 |
+
s1_logits = s1_logits[:, -1, :]
|
| 438 |
+
sample_pre = sample_from_logits(s1_logits, temperature=T, top_k=top_k, top_p=top_p, sample_logits=True)
|
| 439 |
+
|
| 440 |
+
s2_logits = model.decode_s2(context, sample_pre)
|
| 441 |
+
s2_logits = s2_logits[:, -1, :]
|
| 442 |
+
sample_post = sample_from_logits(s2_logits, temperature=T, top_k=top_k, top_p=top_p, sample_logits=True)
|
| 443 |
+
|
| 444 |
+
generated_pre[:, i] = sample_pre.squeeze(-1)
|
| 445 |
+
generated_post[:, i] = sample_post.squeeze(-1)
|
| 446 |
+
|
| 447 |
+
if current_seq_len < max_context:
|
| 448 |
+
pre_buffer[:, current_seq_len] = sample_pre.squeeze(-1)
|
| 449 |
+
post_buffer[:, current_seq_len] = sample_post.squeeze(-1)
|
| 450 |
+
else:
|
| 451 |
+
pre_buffer.copy_(torch.roll(pre_buffer, shifts=-1, dims=1))
|
| 452 |
+
post_buffer.copy_(torch.roll(post_buffer, shifts=-1, dims=1))
|
| 453 |
+
pre_buffer[:, -1] = sample_pre.squeeze(-1)
|
| 454 |
+
post_buffer[:, -1] = sample_post.squeeze(-1)
|
| 455 |
+
|
| 456 |
+
full_pre = torch.cat([x_token[0], generated_pre], dim=1)
|
| 457 |
+
full_post = torch.cat([x_token[1], generated_post], dim=1)
|
| 458 |
+
|
| 459 |
+
context_start = max(0, total_seq_len - max_context)
|
| 460 |
+
input_tokens = [
|
| 461 |
+
full_pre[:, context_start:total_seq_len].contiguous(),
|
| 462 |
+
full_post[:, context_start:total_seq_len].contiguous()
|
| 463 |
+
]
|
| 464 |
+
z = tokenizer.decode(input_tokens, half=True)
|
| 465 |
+
z = z.reshape(-1, sample_count, z.size(1), z.size(2))
|
| 466 |
+
preds = z.cpu().numpy()
|
| 467 |
+
|
| 468 |
+
if not return_samples:
|
| 469 |
+
preds = np.mean(preds, axis=1)
|
| 470 |
+
|
| 471 |
+
return preds
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def calc_time_stamps(x_timestamp):
|
| 475 |
+
time_df = pd.DataFrame()
|
| 476 |
+
time_df['minute'] = x_timestamp.dt.minute
|
| 477 |
+
time_df['hour'] = x_timestamp.dt.hour
|
| 478 |
+
time_df['weekday'] = x_timestamp.dt.weekday
|
| 479 |
+
time_df['day'] = x_timestamp.dt.day
|
| 480 |
+
time_df['month'] = x_timestamp.dt.month
|
| 481 |
+
return time_df
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
class KronosPredictor:
|
| 485 |
+
|
| 486 |
+
def __init__(self, model, tokenizer, device=None, max_context=512, clip=5):
|
| 487 |
+
self.tokenizer = tokenizer
|
| 488 |
+
self.model = model
|
| 489 |
+
self.max_context = max_context
|
| 490 |
+
self.clip = clip
|
| 491 |
+
self.price_cols = ['open', 'high', 'low', 'close']
|
| 492 |
+
self.vol_col = 'volume'
|
| 493 |
+
self.amt_vol = 'amount'
|
| 494 |
+
self.time_cols = ['minute', 'hour', 'weekday', 'day', 'month']
|
| 495 |
+
|
| 496 |
+
# Auto-detect device if not specified
|
| 497 |
+
if device is None:
|
| 498 |
+
if torch.cuda.is_available():
|
| 499 |
+
device = "cuda:0"
|
| 500 |
+
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
| 501 |
+
device = "mps"
|
| 502 |
+
else:
|
| 503 |
+
device = "cpu"
|
| 504 |
+
|
| 505 |
+
self.device = device
|
| 506 |
+
|
| 507 |
+
self.tokenizer = self.tokenizer.to(self.device)
|
| 508 |
+
self.model = self.model.to(self.device)
|
| 509 |
+
|
| 510 |
+
def generate(self, x, x_stamp, y_stamp, pred_len, T, top_k, top_p, sample_count, verbose, return_samples=False):
|
| 511 |
+
|
| 512 |
+
x_tensor = torch.from_numpy(np.array(x).astype(np.float32)).to(self.device)
|
| 513 |
+
x_stamp_tensor = torch.from_numpy(np.array(x_stamp).astype(np.float32)).to(self.device)
|
| 514 |
+
y_stamp_tensor = torch.from_numpy(np.array(y_stamp).astype(np.float32)).to(self.device)
|
| 515 |
+
|
| 516 |
+
preds = auto_regressive_inference(self.tokenizer, self.model, x_tensor, x_stamp_tensor, y_stamp_tensor, self.max_context, pred_len,
|
| 517 |
+
self.clip, T, top_k, top_p, sample_count, verbose, return_samples=return_samples)
|
| 518 |
+
preds = preds[:, -pred_len:, :]
|
| 519 |
+
return preds
|
| 520 |
+
|
| 521 |
+
def predict(self, df, x_timestamp, y_timestamp, pred_len, T=1.0, top_k=0, top_p=0.9, sample_count=1, verbose=True):
|
| 522 |
+
|
| 523 |
+
if not isinstance(df, pd.DataFrame):
|
| 524 |
+
raise ValueError("Input must be a pandas DataFrame.")
|
| 525 |
+
|
| 526 |
+
if not all(col in df.columns for col in self.price_cols):
|
| 527 |
+
raise ValueError(f"Price columns {self.price_cols} not found in DataFrame.")
|
| 528 |
+
|
| 529 |
+
df = df.copy()
|
| 530 |
+
if self.vol_col not in df.columns:
|
| 531 |
+
df[self.vol_col] = 0.0 # Fill missing volume with zeros
|
| 532 |
+
df[self.amt_vol] = 0.0 # Fill missing amount with zeros
|
| 533 |
+
if self.amt_vol not in df.columns and self.vol_col in df.columns:
|
| 534 |
+
df[self.amt_vol] = df[self.vol_col] * df[self.price_cols].mean(axis=1)
|
| 535 |
+
|
| 536 |
+
if df[self.price_cols + [self.vol_col, self.amt_vol]].isnull().values.any():
|
| 537 |
+
raise ValueError("Input DataFrame contains NaN values in price or volume columns.")
|
| 538 |
+
|
| 539 |
+
x_time_df = calc_time_stamps(x_timestamp)
|
| 540 |
+
y_time_df = calc_time_stamps(y_timestamp)
|
| 541 |
+
|
| 542 |
+
x = df[self.price_cols + [self.vol_col, self.amt_vol]].values.astype(np.float32)
|
| 543 |
+
x_stamp = x_time_df.values.astype(np.float32)
|
| 544 |
+
y_stamp = y_time_df.values.astype(np.float32)
|
| 545 |
+
|
| 546 |
+
x_mean, x_std = np.mean(x, axis=0), np.std(x, axis=0)
|
| 547 |
+
|
| 548 |
+
x = (x - x_mean) / (x_std + 1e-5)
|
| 549 |
+
x = np.clip(x, -self.clip, self.clip)
|
| 550 |
+
|
| 551 |
+
x = x[np.newaxis, :]
|
| 552 |
+
x_stamp = x_stamp[np.newaxis, :]
|
| 553 |
+
y_stamp = y_stamp[np.newaxis, :]
|
| 554 |
+
|
| 555 |
+
preds = self.generate(x, x_stamp, y_stamp, pred_len, T, top_k, top_p, sample_count, verbose)
|
| 556 |
+
|
| 557 |
+
preds = preds.squeeze(0)
|
| 558 |
+
preds = preds * (x_std + 1e-5) + x_mean
|
| 559 |
+
|
| 560 |
+
pred_df = pd.DataFrame(preds, columns=self.price_cols + [self.vol_col, self.amt_vol], index=y_timestamp)
|
| 561 |
+
return pred_df
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def predict_batch(self, df_list, x_timestamp_list, y_timestamp_list, pred_len, T=1.0, top_k=0, top_p=0.9, sample_count=1, verbose=True):
|
| 565 |
+
"""
|
| 566 |
+
Perform parallel (batch) prediction on multiple time series. All series must have the same historical length and prediction length (pred_len).
|
| 567 |
+
|
| 568 |
+
Args:
|
| 569 |
+
df_list (List[pd.DataFrame]): List of input DataFrames, each containing price columns and optional volume/amount columns.
|
| 570 |
+
x_timestamp_list (List[pd.DatetimeIndex or Series]): List of timestamps corresponding to historical data, length should match the number of rows in each DataFrame.
|
| 571 |
+
y_timestamp_list (List[pd.DatetimeIndex or Series]): List of future prediction timestamps, length should equal pred_len.
|
| 572 |
+
pred_len (int): Number of prediction steps.
|
| 573 |
+
T (float): Sampling temperature.
|
| 574 |
+
top_k (int): Top-k filtering threshold.
|
| 575 |
+
top_p (float): Top-p (nucleus sampling) threshold.
|
| 576 |
+
sample_count (int): Number of parallel samples per series, automatically averaged internally.
|
| 577 |
+
verbose (bool): Whether to display autoregressive progress.
|
| 578 |
+
|
| 579 |
+
Returns:
|
| 580 |
+
List[pd.DataFrame]: List of prediction results in the same order as input, each DataFrame contains
|
| 581 |
+
`open, high, low, close, volume, amount` columns, indexed by corresponding `y_timestamp`.
|
| 582 |
+
"""
|
| 583 |
+
# Basic validation
|
| 584 |
+
if not isinstance(df_list, (list, tuple)) or not isinstance(x_timestamp_list, (list, tuple)) or not isinstance(y_timestamp_list, (list, tuple)):
|
| 585 |
+
raise ValueError("df_list, x_timestamp_list, y_timestamp_list must be list or tuple types.")
|
| 586 |
+
if not (len(df_list) == len(x_timestamp_list) == len(y_timestamp_list)):
|
| 587 |
+
raise ValueError("df_list, x_timestamp_list, y_timestamp_list must have consistent lengths.")
|
| 588 |
+
|
| 589 |
+
num_series = len(df_list)
|
| 590 |
+
|
| 591 |
+
x_list = []
|
| 592 |
+
x_stamp_list = []
|
| 593 |
+
y_stamp_list = []
|
| 594 |
+
means = []
|
| 595 |
+
stds = []
|
| 596 |
+
seq_lens = []
|
| 597 |
+
y_lens = []
|
| 598 |
+
|
| 599 |
+
for i in range(num_series):
|
| 600 |
+
df = df_list[i]
|
| 601 |
+
if not isinstance(df, pd.DataFrame):
|
| 602 |
+
raise ValueError(f"Input at index {i} is not a pandas DataFrame.")
|
| 603 |
+
if not all(col in df.columns for col in self.price_cols):
|
| 604 |
+
raise ValueError(f"DataFrame at index {i} is missing price columns {self.price_cols}.")
|
| 605 |
+
|
| 606 |
+
df = df.copy()
|
| 607 |
+
if self.vol_col not in df.columns:
|
| 608 |
+
df[self.vol_col] = 0.0
|
| 609 |
+
df[self.amt_vol] = 0.0
|
| 610 |
+
if self.amt_vol not in df.columns and self.vol_col in df.columns:
|
| 611 |
+
df[self.amt_vol] = df[self.vol_col] * df[self.price_cols].mean(axis=1)
|
| 612 |
+
|
| 613 |
+
if df[self.price_cols + [self.vol_col, self.amt_vol]].isnull().values.any():
|
| 614 |
+
raise ValueError(f"DataFrame at index {i} contains NaN values in price or volume columns.")
|
| 615 |
+
|
| 616 |
+
x_timestamp = x_timestamp_list[i]
|
| 617 |
+
y_timestamp = y_timestamp_list[i]
|
| 618 |
+
|
| 619 |
+
x_time_df = calc_time_stamps(x_timestamp)
|
| 620 |
+
y_time_df = calc_time_stamps(y_timestamp)
|
| 621 |
+
|
| 622 |
+
x = df[self.price_cols + [self.vol_col, self.amt_vol]].values.astype(np.float32)
|
| 623 |
+
x_stamp = x_time_df.values.astype(np.float32)
|
| 624 |
+
y_stamp = y_time_df.values.astype(np.float32)
|
| 625 |
+
|
| 626 |
+
if x.shape[0] != x_stamp.shape[0]:
|
| 627 |
+
raise ValueError(f"Inconsistent lengths at index {i}: x has {x.shape[0]} vs x_stamp has {x_stamp.shape[0]}.")
|
| 628 |
+
if y_stamp.shape[0] != pred_len:
|
| 629 |
+
raise ValueError(f"y_timestamp length at index {i} should equal pred_len={pred_len}, got {y_stamp.shape[0]}.")
|
| 630 |
+
|
| 631 |
+
x_mean, x_std = np.mean(x, axis=0), np.std(x, axis=0)
|
| 632 |
+
x_norm = (x - x_mean) / (x_std + 1e-5)
|
| 633 |
+
x_norm = np.clip(x_norm, -self.clip, self.clip)
|
| 634 |
+
|
| 635 |
+
x_list.append(x_norm)
|
| 636 |
+
x_stamp_list.append(x_stamp)
|
| 637 |
+
y_stamp_list.append(y_stamp)
|
| 638 |
+
means.append(x_mean)
|
| 639 |
+
stds.append(x_std)
|
| 640 |
+
|
| 641 |
+
seq_lens.append(x_norm.shape[0])
|
| 642 |
+
y_lens.append(y_stamp.shape[0])
|
| 643 |
+
|
| 644 |
+
# Require all series to have consistent historical and prediction lengths for batch processing
|
| 645 |
+
if len(set(seq_lens)) != 1:
|
| 646 |
+
raise ValueError(f"Parallel prediction requires all series to have consistent historical lengths, got: {seq_lens}")
|
| 647 |
+
if len(set(y_lens)) != 1:
|
| 648 |
+
raise ValueError(f"Parallel prediction requires all series to have consistent prediction lengths, got: {y_lens}")
|
| 649 |
+
|
| 650 |
+
x_batch = np.stack(x_list, axis=0).astype(np.float32) # (B, seq_len, feat)
|
| 651 |
+
x_stamp_batch = np.stack(x_stamp_list, axis=0).astype(np.float32) # (B, seq_len, time_feat)
|
| 652 |
+
y_stamp_batch = np.stack(y_stamp_list, axis=0).astype(np.float32) # (B, pred_len, time_feat)
|
| 653 |
+
|
| 654 |
+
preds = self.generate(x_batch, x_stamp_batch, y_stamp_batch, pred_len, T, top_k, top_p, sample_count, verbose)
|
| 655 |
+
# preds: (B, pred_len, feat)
|
| 656 |
+
|
| 657 |
+
pred_dfs = []
|
| 658 |
+
for i in range(num_series):
|
| 659 |
+
preds_i = preds[i] * (stds[i] + 1e-5) + means[i]
|
| 660 |
+
pred_df = pd.DataFrame(preds_i, columns=self.price_cols + [self.vol_col, self.amt_vol], index=y_timestamp_list[i])
|
| 661 |
+
pred_dfs.append(pred_df)
|
| 662 |
+
|
| 663 |
+
return pred_dfs
|
| 664 |
+
|
Kronos-master/model/module.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
|
| 3 |
+
from einops import rearrange, reduce
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
from torch.autograd import Function
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class DifferentiableEntropyFunction(Function):
|
| 11 |
+
@staticmethod
|
| 12 |
+
def forward(ctx, zq, basis, K, eps):
|
| 13 |
+
zb = (zq + 1) / 2
|
| 14 |
+
zi = ((zb * basis).sum(-1)).to(torch.int64)
|
| 15 |
+
cnt = torch.scatter_reduce(torch.zeros(2 ** K, device=zq.device, dtype=zq.dtype),
|
| 16 |
+
0,
|
| 17 |
+
zi.flatten(),
|
| 18 |
+
torch.ones_like(zi.flatten()).to(zq.dtype),
|
| 19 |
+
'sum')
|
| 20 |
+
prob = (cnt + eps) / (cnt + eps).sum()
|
| 21 |
+
H = -(prob * torch.log(prob)).sum()
|
| 22 |
+
ctx.save_for_backward(zq, zi, prob)
|
| 23 |
+
ctx.K = K
|
| 24 |
+
return H
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
def backward(ctx, grad_output):
|
| 28 |
+
zq, zi, prob = ctx.saved_tensors
|
| 29 |
+
grad_array = -grad_output * (torch.log(prob) + 1) / zi.numel() / ctx.K
|
| 30 |
+
reord_grad = grad_array[zi.flatten()].reshape(zi.shape)
|
| 31 |
+
grad_input = reord_grad.unsqueeze(-1) * zq
|
| 32 |
+
return grad_input, None, None, None, None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def codebook_entropy(zq, basis, K, eps=1e-4):
|
| 36 |
+
return DifferentiableEntropyFunction.apply(zq, basis, K, eps)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class BinarySphericalQuantizer(nn.Module):
|
| 40 |
+
def __init__(self, embed_dim, beta, gamma0, gamma, zeta,
|
| 41 |
+
input_format='bchw',
|
| 42 |
+
soft_entropy=True, group_size=9,
|
| 43 |
+
persample_entropy_compute='analytical',
|
| 44 |
+
cb_entropy_compute='group',
|
| 45 |
+
l2_norm=True,
|
| 46 |
+
inv_temperature=1):
|
| 47 |
+
"""
|
| 48 |
+
Paper link: https://arxiv.org/pdf/2406.07548.pdf
|
| 49 |
+
Here we use the official implementation of the BinarySphericalQuantizer.
|
| 50 |
+
"""
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.embed_dim = embed_dim
|
| 53 |
+
self.beta = beta # loss weight for commit loss
|
| 54 |
+
self.gamma0 = gamma0 # loss weight for entropy penalty
|
| 55 |
+
self.gamma = gamma # loss weight for entropy penalty
|
| 56 |
+
self.zeta = zeta # loss weight for entire entropy penalty
|
| 57 |
+
self.input_format = input_format
|
| 58 |
+
assert self.embed_dim % group_size == 0, "embed_dim must be divisible by group_size"
|
| 59 |
+
self.num_groups = self.embed_dim // group_size
|
| 60 |
+
self.group_size = group_size
|
| 61 |
+
assert persample_entropy_compute in ['group', 'analytical'], "persample_entropy_compute must be either 'group' or 'analytical'"
|
| 62 |
+
assert cb_entropy_compute in ['group', 'nce'], "cb_entropy_compute must be either 'group' or 'nce'"
|
| 63 |
+
self.persample_entropy_compute = persample_entropy_compute
|
| 64 |
+
self.cb_entropy_compute = cb_entropy_compute
|
| 65 |
+
self.l2_norm = l2_norm
|
| 66 |
+
self.inv_temperature = inv_temperature
|
| 67 |
+
|
| 68 |
+
self.register_buffer('basis', 2 ** torch.arange(embed_dim - 1, -1, -1))
|
| 69 |
+
self.register_buffer('group_basis', 2 ** torch.arange(group_size - 1, -1, -1))
|
| 70 |
+
|
| 71 |
+
self.num_dimensions = 2 ** embed_dim
|
| 72 |
+
self.bits_per_index = embed_dim
|
| 73 |
+
|
| 74 |
+
# we only need to keep the codebook portion up to the group size
|
| 75 |
+
# because we approximate the H loss with this subcode
|
| 76 |
+
group_codes = torch.arange(2 ** self.group_size)
|
| 77 |
+
group_codebook = self.indexes_to_codes(group_codes).float()[:, -group_size:]
|
| 78 |
+
self.register_buffer('group_codebook', group_codebook, persistent=False)
|
| 79 |
+
|
| 80 |
+
self.soft_entropy = soft_entropy # soft_entropy: Sec 3.2 of https://arxiv.org/pdf/1911.05894.pdf
|
| 81 |
+
|
| 82 |
+
def quantize(self, z):
|
| 83 |
+
assert z.shape[-1] == self.embed_dim, f"Expected {self.embed_dim} dimensions, got {z.shape[-1]}"
|
| 84 |
+
|
| 85 |
+
zhat = torch.where(z > 0,
|
| 86 |
+
torch.tensor(1, dtype=z.dtype, device=z.device),
|
| 87 |
+
torch.tensor(-1, dtype=z.dtype, device=z.device))
|
| 88 |
+
return z + (zhat - z).detach()
|
| 89 |
+
|
| 90 |
+
def forward(self, z, collect_metrics=True):
|
| 91 |
+
# if self.input_format == 'bchw':
|
| 92 |
+
# z = rearrange(z, 'b c h w -> b h w c')
|
| 93 |
+
zq = self.quantize(z)
|
| 94 |
+
|
| 95 |
+
q_scale = 1. / (self.embed_dim ** 0.5) if self.l2_norm else 1.
|
| 96 |
+
|
| 97 |
+
zq = zq * q_scale
|
| 98 |
+
|
| 99 |
+
if not collect_metrics:
|
| 100 |
+
return zq, zq.new_zeros(()), {}
|
| 101 |
+
|
| 102 |
+
indices = self.codes_to_indexes(zq.detach())
|
| 103 |
+
group_indices = self.codes_to_group_indexes(zq.detach())
|
| 104 |
+
if not self.training:
|
| 105 |
+
used_codes = torch.unique(indices, return_counts=False)
|
| 106 |
+
else:
|
| 107 |
+
used_codes = None
|
| 108 |
+
|
| 109 |
+
if self.soft_entropy:
|
| 110 |
+
persample_entropy, cb_entropy, avg_prob = self.soft_entropy_loss(z)
|
| 111 |
+
entropy_penalty = self.gamma0 * persample_entropy - self.gamma * cb_entropy
|
| 112 |
+
else:
|
| 113 |
+
zb_by_sample = ((zq + 1) / 2).reshape(z.shape[0], -1, z.shape[-1]).to(torch.float32)
|
| 114 |
+
persample_entropy = self.get_hard_per_sample_entropy(zb_by_sample)
|
| 115 |
+
cb_entropy = codebook_entropy(zq, self.basis, self.embed_dim)
|
| 116 |
+
entropy_penalty = self.gamma0 * persample_entropy - self.gamma * cb_entropy
|
| 117 |
+
|
| 118 |
+
# commit loss
|
| 119 |
+
commit_loss = self.beta * torch.mean(((zq.detach() - z) ** 2).sum(dim=-1))
|
| 120 |
+
|
| 121 |
+
# if self.input_format == 'bchw':
|
| 122 |
+
# zq = rearrange(zq, 'b h w c -> b c h w')
|
| 123 |
+
|
| 124 |
+
return (
|
| 125 |
+
zq,
|
| 126 |
+
commit_loss + self.zeta * entropy_penalty / self.inv_temperature,
|
| 127 |
+
{"H": cb_entropy, "used_codes": used_codes, "indices": indices, "group_indices": group_indices,
|
| 128 |
+
"avg_prob": avg_prob}
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def soft_entropy_loss(self, z):
|
| 132 |
+
# if we divide the code in subgroups of size group_size, the codebook will be of size 2 ** group_size
|
| 133 |
+
# the sub-code is the last group_size bits of the full code
|
| 134 |
+
group_code_book = self.group_codebook / (self.embed_dim ** 0.5 if self.l2_norm else 1)
|
| 135 |
+
divided_z = rearrange(z, '... (g c) -> ... g c', c=self.group_size)
|
| 136 |
+
|
| 137 |
+
# we calculate the distance between the divided_z and the codebook for each subgroup
|
| 138 |
+
distance = - 2 * torch.einsum('... g c, d c ->... g d', divided_z, group_code_book)
|
| 139 |
+
prob = (-distance * self.inv_temperature).softmax(dim=-1)
|
| 140 |
+
if self.persample_entropy_compute == 'analytical':
|
| 141 |
+
if self.l2_norm:
|
| 142 |
+
p = torch.sigmoid(-4 * z / (self.embed_dim ** 0.5) * self.inv_temperature)
|
| 143 |
+
else:
|
| 144 |
+
p = torch.sigmoid(-4 * z * self.inv_temperature)
|
| 145 |
+
prob = torch.stack([p, 1 - p], dim=-1)
|
| 146 |
+
per_sample_entropy = self.get_entropy(prob, dim=-1, normalize=False).sum(dim=-1).mean()
|
| 147 |
+
else:
|
| 148 |
+
per_sample_entropy = self.get_entropy(prob, dim=-1, normalize=False).sum(dim=-1).mean()
|
| 149 |
+
|
| 150 |
+
# macro average of the probability of each subgroup
|
| 151 |
+
avg_prob = reduce(prob, '... g d ->g d', 'mean')
|
| 152 |
+
codebook_entropy = self.get_entropy(avg_prob, dim=-1, normalize=False)
|
| 153 |
+
|
| 154 |
+
# the approximation of the entropy is the sum of the entropy of each subgroup
|
| 155 |
+
return per_sample_entropy, codebook_entropy.sum(), avg_prob
|
| 156 |
+
|
| 157 |
+
def get_hard_per_sample_entropy(self, zb_by_sample):
|
| 158 |
+
probs_per_dim = zb_by_sample.sum(1) / zb_by_sample.shape[1]
|
| 159 |
+
persample_entropy = - probs_per_dim * torch.log(probs_per_dim + 1e-8) - (1 - probs_per_dim) * torch.log(1 - probs_per_dim + 1e-8)
|
| 160 |
+
persample_entropy = persample_entropy.sum(-1)
|
| 161 |
+
return persample_entropy.mean()
|
| 162 |
+
|
| 163 |
+
def codes_to_indexes(self, zhat):
|
| 164 |
+
"""Converts a `code` to an index in the codebook.
|
| 165 |
+
Args:
|
| 166 |
+
zhat: A tensor of shape (B, ..., C) containing the codes. must be in {-1, 1}
|
| 167 |
+
"""
|
| 168 |
+
assert zhat.shape[-1] == self.embed_dim, f"Expected {self.embed_dim} dimensions, got {zhat.shape[-1]}"
|
| 169 |
+
return ((zhat + 1) / 2 * self.basis).sum(axis=-1).to(torch.int64)
|
| 170 |
+
|
| 171 |
+
def codes_to_group_indexes(self, zhat):
|
| 172 |
+
"""Converts a `code` to a list of indexes (in groups) in the codebook.
|
| 173 |
+
Args:
|
| 174 |
+
zhat: A tensor of shape (B, ..., C) containing the codes. must be in {-1, 1}
|
| 175 |
+
"""
|
| 176 |
+
zhat_in_group = rearrange(zhat, 'b ... (g c) -> b ... g c', c=self.group_size)
|
| 177 |
+
return ((zhat_in_group + 1) / 2 * self.group_basis).sum(axis=-1).to(torch.int64)
|
| 178 |
+
|
| 179 |
+
def indexes_to_codes(self, indices):
|
| 180 |
+
"""Inverse of `indexes_to_codes`."""
|
| 181 |
+
indices = indices.unsqueeze(-1)
|
| 182 |
+
codes_non_centered = torch.remainder(
|
| 183 |
+
torch.floor_divide(indices, self.basis), 2
|
| 184 |
+
)
|
| 185 |
+
return codes_non_centered * 2 - 1
|
| 186 |
+
|
| 187 |
+
def group_indexes_to_codes(self, group_indices):
|
| 188 |
+
"""Inverse of `group_indexes_to_codes`."""
|
| 189 |
+
group_indices = group_indices.unsqueeze(-1)
|
| 190 |
+
codes_non_centered = torch.remainder(
|
| 191 |
+
torch.floor_divide(group_indices, self.group_basis), 2
|
| 192 |
+
)
|
| 193 |
+
codes_non_centered = rearrange(codes_non_centered, 'b ... g c -> b ... (g c)')
|
| 194 |
+
return codes_non_centered * 2 - 1
|
| 195 |
+
|
| 196 |
+
def get_entropy(self, count, dim=-1, eps=1e-4, normalize=True):
|
| 197 |
+
if normalize:
|
| 198 |
+
probs = (count + eps) / (count + eps).sum(dim=dim, keepdim=True)
|
| 199 |
+
else:
|
| 200 |
+
probs = count
|
| 201 |
+
H = -(probs * torch.log(probs + 1e-8)).sum(dim=dim)
|
| 202 |
+
return H
|
| 203 |
+
|
| 204 |
+
def get_group_codebook_entry(self, group_indices):
|
| 205 |
+
z_q = self.group_indexes_to_codes(group_indices)
|
| 206 |
+
q_scale = 1. / (self.embed_dim ** 0.5) if self.l2_norm else 1.
|
| 207 |
+
z_q = z_q * q_scale
|
| 208 |
+
if self.input_format == 'bchw':
|
| 209 |
+
h, w = int(z_q.shape[1] ** 0.5)
|
| 210 |
+
assert h * w == z_q.shape[1], 'Invalid sequence length'
|
| 211 |
+
z_q = rearrange(z_q, 'b (h w) c -> b c h w', h=h)
|
| 212 |
+
return z_q
|
| 213 |
+
|
| 214 |
+
def get_codebook_entry(self, indices):
|
| 215 |
+
z_q = self.indexes_to_codes(indices)
|
| 216 |
+
q_scale = 1. / (self.embed_dim ** 0.5) if self.l2_norm else 1.
|
| 217 |
+
z_q = z_q * q_scale
|
| 218 |
+
if self.input_format == 'bchw':
|
| 219 |
+
h, w = int(z_q.shape[1] ** 0.5)
|
| 220 |
+
assert h * w == z_q.shape[1], 'Invalid sequence length'
|
| 221 |
+
z_q = rearrange(z_q, 'b (h w) c -> b c h w', h=h)
|
| 222 |
+
return z_q
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class BSQuantizer(nn.Module):
|
| 226 |
+
|
| 227 |
+
def __init__(self, s1_bits, s2_bits, beta, gamma0, gamma, zeta, group_size):
|
| 228 |
+
super().__init__()
|
| 229 |
+
self.codebook_dim = s1_bits + s2_bits
|
| 230 |
+
self.s1_bits = s1_bits
|
| 231 |
+
self.s2_bits = s2_bits
|
| 232 |
+
self.bsq = BinarySphericalQuantizer(self.codebook_dim, beta, gamma0, gamma, zeta, group_size=group_size)
|
| 233 |
+
|
| 234 |
+
def bits_to_indices(self, bits):
|
| 235 |
+
bits = (bits >= 0).to(torch.long)
|
| 236 |
+
indices = 2 ** torch.arange(
|
| 237 |
+
0,
|
| 238 |
+
bits.shape[-1],
|
| 239 |
+
1,
|
| 240 |
+
dtype=torch.long,
|
| 241 |
+
device=bits.device,
|
| 242 |
+
)
|
| 243 |
+
return (bits * indices).sum(-1)
|
| 244 |
+
|
| 245 |
+
def forward(self, z, half=False, collect_metrics=True):
|
| 246 |
+
z = F.normalize(z, dim=-1)
|
| 247 |
+
quantized, bsq_loss, metrics = self.bsq(z, collect_metrics=collect_metrics)
|
| 248 |
+
if half:
|
| 249 |
+
q_pre = quantized[:, :, :self.s1_bits]
|
| 250 |
+
q_post = quantized[:, :, self.s1_bits:]
|
| 251 |
+
z_indices = [self.bits_to_indices(q_pre), self.bits_to_indices(q_post)]
|
| 252 |
+
else:
|
| 253 |
+
z_indices = self.bits_to_indices(quantized)
|
| 254 |
+
return bsq_loss, quantized, z_indices
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
class RMSNorm(torch.nn.Module):
|
| 258 |
+
def __init__(self, dim: int, eps: float = 1e-5):
|
| 259 |
+
super().__init__()
|
| 260 |
+
self.eps = eps
|
| 261 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 262 |
+
|
| 263 |
+
def _norm(self, x):
|
| 264 |
+
return x * torch.rsqrt(torch.mean(x * x, dim=-1, keepdim=True) + self.eps)
|
| 265 |
+
|
| 266 |
+
def forward(self, x):
|
| 267 |
+
output = self._norm(x.float()).type_as(x)
|
| 268 |
+
return output * self.weight
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
class FeedForward(nn.Module):
|
| 272 |
+
def __init__(self, d_model, ff_dim, ffn_dropout_p=0.0):
|
| 273 |
+
super().__init__()
|
| 274 |
+
|
| 275 |
+
self.w1 = nn.Linear(d_model, ff_dim, bias=False)
|
| 276 |
+
self.w3 = nn.Linear(d_model, ff_dim, bias=False)
|
| 277 |
+
self.w2 = nn.Linear(ff_dim, d_model, bias=False)
|
| 278 |
+
self.ffn_dropout = nn.Dropout(ffn_dropout_p)
|
| 279 |
+
|
| 280 |
+
def forward(self, x):
|
| 281 |
+
return self.ffn_dropout(self.w2(F.silu(self.w1(x)) * self.w3(x)))
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
class RotaryPositionalEmbedding(nn.Module):
|
| 285 |
+
def __init__(self, dim):
|
| 286 |
+
super().__init__()
|
| 287 |
+
inv_freq = 1.0 / (10000 ** (torch.arange(0, dim, 2).float() / dim))
|
| 288 |
+
self.register_buffer("inv_freq", inv_freq)
|
| 289 |
+
self.seq_len_cached = None
|
| 290 |
+
self.cos_cached = None
|
| 291 |
+
self.sin_cached = None
|
| 292 |
+
|
| 293 |
+
def _update_cos_sin_cache(self, x, seq_len):
|
| 294 |
+
if seq_len != self.seq_len_cached:
|
| 295 |
+
self.seq_len_cached = seq_len
|
| 296 |
+
t = torch.arange(seq_len, device=x.device).type_as(self.inv_freq)
|
| 297 |
+
freqs = torch.einsum('i,j->ij', t, self.inv_freq)
|
| 298 |
+
emb = torch.cat((freqs, freqs), dim=-1).to(x.device)
|
| 299 |
+
self.cos_cached = emb.cos()[None, None, :, :]
|
| 300 |
+
self.sin_cached = emb.sin()[None, None, :, :]
|
| 301 |
+
return self.cos_cached, self.sin_cached
|
| 302 |
+
|
| 303 |
+
def forward(self, q, k):
|
| 304 |
+
cos, sin = self._update_cos_sin_cache(q, q.shape[-2])
|
| 305 |
+
return (
|
| 306 |
+
(q * cos) + (self._rotate_half(q) * sin),
|
| 307 |
+
(k * cos) + (self._rotate_half(k) * sin),
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
def _rotate_half(self, x):
|
| 311 |
+
x1, x2 = x.chunk(2, dim=-1)
|
| 312 |
+
return torch.cat((-x2, x1), dim=-1)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
class MultiHeadAttentionWithRoPE(nn.Module):
|
| 316 |
+
def __init__(self, d_model, n_heads, attn_dropout_p=0.0, resid_dropout_p=0.0):
|
| 317 |
+
super().__init__()
|
| 318 |
+
self.d_model = d_model
|
| 319 |
+
self.n_heads = n_heads
|
| 320 |
+
self.head_dim = d_model // n_heads
|
| 321 |
+
|
| 322 |
+
self.q_proj = nn.Linear(d_model, d_model)
|
| 323 |
+
self.k_proj = nn.Linear(d_model, d_model)
|
| 324 |
+
self.v_proj = nn.Linear(d_model, d_model)
|
| 325 |
+
self.out_proj = nn.Linear(d_model, d_model)
|
| 326 |
+
self.rotary = RotaryPositionalEmbedding(self.head_dim)
|
| 327 |
+
self.attn_dropout_p = attn_dropout_p
|
| 328 |
+
self.resid_dropout = nn.Dropout(resid_dropout_p)
|
| 329 |
+
|
| 330 |
+
def forward(self, x, key_padding_mask=None):
|
| 331 |
+
batch_size, seq_len, _ = x.shape
|
| 332 |
+
|
| 333 |
+
q = self.q_proj(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
|
| 334 |
+
k = self.k_proj(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
|
| 335 |
+
v = self.v_proj(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
|
| 336 |
+
|
| 337 |
+
q, k = self.rotary(q, k)
|
| 338 |
+
|
| 339 |
+
if key_padding_mask is not None:
|
| 340 |
+
attn_mask = key_padding_mask.unsqueeze(1).unsqueeze(2) # [batch, 1, 1, seq_len]
|
| 341 |
+
attn_mask = attn_mask.expand(-1, self.n_heads, seq_len, -1) # [batch, n_heads, q_len, k_len]
|
| 342 |
+
else:
|
| 343 |
+
attn_mask = None
|
| 344 |
+
|
| 345 |
+
attn_output = F.scaled_dot_product_attention(
|
| 346 |
+
q, k, v,
|
| 347 |
+
attn_mask=attn_mask,
|
| 348 |
+
dropout_p=self.attn_dropout_p if self.training else 0.0,
|
| 349 |
+
is_causal=True
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
|
| 353 |
+
return self.resid_dropout(self.out_proj(attn_output))
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
class MultiHeadCrossAttentionWithRoPE(nn.Module):
|
| 357 |
+
def __init__(self, d_model, n_heads, attn_dropout_p=0.0, resid_dropout=0.0):
|
| 358 |
+
super().__init__()
|
| 359 |
+
self.d_model = d_model
|
| 360 |
+
self.n_heads = n_heads
|
| 361 |
+
self.head_dim = d_model // n_heads
|
| 362 |
+
|
| 363 |
+
self.q_proj = nn.Linear(d_model, d_model)
|
| 364 |
+
self.k_proj = nn.Linear(d_model, d_model)
|
| 365 |
+
self.v_proj = nn.Linear(d_model, d_model)
|
| 366 |
+
self.out_proj = nn.Linear(d_model, d_model)
|
| 367 |
+
self.rotary = RotaryPositionalEmbedding(self.head_dim)
|
| 368 |
+
self.attn_dropout_p = attn_dropout_p
|
| 369 |
+
self.resid_dropout = nn.Dropout(resid_dropout)
|
| 370 |
+
|
| 371 |
+
def forward(self, query, key, value, key_padding_mask=None):
|
| 372 |
+
batch_size, q_len, _ = query.shape
|
| 373 |
+
_, seq_len, _ = key.shape
|
| 374 |
+
|
| 375 |
+
q = self.q_proj(query).view(batch_size, q_len, self.n_heads, self.head_dim).transpose(1, 2)
|
| 376 |
+
k = self.k_proj(key).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
|
| 377 |
+
v = self.v_proj(value).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
|
| 378 |
+
|
| 379 |
+
q, k = self.rotary(q, k)
|
| 380 |
+
|
| 381 |
+
if key_padding_mask is not None:
|
| 382 |
+
attn_mask = key_padding_mask.unsqueeze(1).unsqueeze(2)
|
| 383 |
+
attn_mask = attn_mask.expand(-1, self.n_heads, q_len, -1)
|
| 384 |
+
else:
|
| 385 |
+
attn_mask = None
|
| 386 |
+
|
| 387 |
+
is_causal_flag = self.training
|
| 388 |
+
|
| 389 |
+
attn_output = F.scaled_dot_product_attention(
|
| 390 |
+
q, k, v,
|
| 391 |
+
attn_mask=attn_mask,
|
| 392 |
+
dropout_p=self.attn_dropout_p if self.training else 0.0,
|
| 393 |
+
is_causal=is_causal_flag
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, q_len, self.d_model)
|
| 397 |
+
return self.resid_dropout(self.out_proj(attn_output))
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
class HierarchicalEmbedding(nn.Module):
|
| 401 |
+
def __init__(self, s1_bits, s2_bits, d_model=256):
|
| 402 |
+
super().__init__()
|
| 403 |
+
self.s1_bits = s1_bits
|
| 404 |
+
self.s2_bits = s2_bits
|
| 405 |
+
|
| 406 |
+
vocab_s1 = 2 ** s1_bits
|
| 407 |
+
vocab_s2 = 2 ** s2_bits
|
| 408 |
+
|
| 409 |
+
self.emb_s1 = nn.Embedding(vocab_s1, d_model)
|
| 410 |
+
self.emb_s2 = nn.Embedding(vocab_s2, d_model)
|
| 411 |
+
self.d_model = d_model
|
| 412 |
+
self.fusion_proj = nn.Linear(d_model * 2, d_model)
|
| 413 |
+
|
| 414 |
+
nn.init.normal_(self.emb_s1.weight, mean=0, std=d_model ** -0.5)
|
| 415 |
+
nn.init.normal_(self.emb_s2.weight, mean=0, std=d_model ** -0.5)
|
| 416 |
+
|
| 417 |
+
def split_token(self, token_ids: torch.Tensor, s2_bits: int):
|
| 418 |
+
"""Inputs:
|
| 419 |
+
token_ids (torch.Tensor): Composite token IDs of shape [batch_size, seq_len] or [N], each in range [0, 2^(s1_bits + s2_bits) - 1].
|
| 420 |
+
s2_bits (int): Number of low bits used for the fine token (s2).
|
| 421 |
+
"""
|
| 422 |
+
assert isinstance(s2_bits, int) and s2_bits > 0, "s2_bits must be a positive integer"
|
| 423 |
+
|
| 424 |
+
t = token_ids.long()
|
| 425 |
+
mask = (1 << s2_bits) - 1
|
| 426 |
+
s2_ids = t & mask # extract low bits
|
| 427 |
+
s1_ids = t >> s2_bits # extract high bits
|
| 428 |
+
return s1_ids, s2_ids
|
| 429 |
+
|
| 430 |
+
def forward(self, token_ids):
|
| 431 |
+
"""Inputs:
|
| 432 |
+
token_ids:
|
| 433 |
+
- tuple or list: (s1_ids, s2_ids), each of shape [batch_size, seq_len], or
|
| 434 |
+
- torch.Tensor: composite token IDs of shape [batch_size, seq_len], which will be split into (s1_ids, s2_ids) internally.
|
| 435 |
+
Output: [batch_size, seq_len, d_model]
|
| 436 |
+
"""
|
| 437 |
+
if isinstance(token_ids, tuple) or isinstance(token_ids, list):
|
| 438 |
+
s1_ids, s2_ids = token_ids
|
| 439 |
+
else:
|
| 440 |
+
s1_ids, s2_ids = self.split_token(token_ids, self.s2_bits)
|
| 441 |
+
s1_emb = self.emb_s1(s1_ids) * math.sqrt(self.d_model)
|
| 442 |
+
s2_emb = self.emb_s2(s2_ids) * math.sqrt(self.d_model)
|
| 443 |
+
return self.fusion_proj(torch.cat([s1_emb, s2_emb], dim=-1))
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
class DependencyAwareLayer(nn.Module):
|
| 447 |
+
def __init__(self, d_model, n_heads=4, attn_dropout_p=0.0, resid_dropout=0.0):
|
| 448 |
+
super().__init__()
|
| 449 |
+
self.cross_attn = MultiHeadCrossAttentionWithRoPE(d_model, n_heads, attn_dropout_p, resid_dropout)
|
| 450 |
+
self.norm = RMSNorm(d_model)
|
| 451 |
+
|
| 452 |
+
def forward(self, hidden_states, sibling_embed, key_padding_mask=None):
|
| 453 |
+
"""hidden_states: [batch, seq_len, d_model]
|
| 454 |
+
sibling_embed: Embedding from another subtoken
|
| 455 |
+
"""
|
| 456 |
+
attn_out = self.cross_attn(
|
| 457 |
+
query=sibling_embed,
|
| 458 |
+
key=hidden_states,
|
| 459 |
+
value=hidden_states,
|
| 460 |
+
key_padding_mask=key_padding_mask
|
| 461 |
+
)
|
| 462 |
+
return self.norm(hidden_states + attn_out)
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
class TransformerBlock(nn.Module):
|
| 466 |
+
def __init__(self, d_model, n_heads, ff_dim=1024, ffn_dropout_p=0.0, attn_dropout_p=0.0, resid_dropout_p=0.0):
|
| 467 |
+
super().__init__()
|
| 468 |
+
self.norm1 = RMSNorm(d_model)
|
| 469 |
+
self.self_attn = MultiHeadAttentionWithRoPE(d_model, n_heads, attn_dropout_p, resid_dropout_p)
|
| 470 |
+
self.norm2 = RMSNorm(d_model)
|
| 471 |
+
self.ffn = FeedForward(d_model, ff_dim, ffn_dropout_p)
|
| 472 |
+
|
| 473 |
+
def forward(self, x, key_padding_mask=None):
|
| 474 |
+
residual = x
|
| 475 |
+
x = self.norm1(x)
|
| 476 |
+
attn_out = self.self_attn(x, key_padding_mask=key_padding_mask)
|
| 477 |
+
x = residual + attn_out
|
| 478 |
+
|
| 479 |
+
residual = x
|
| 480 |
+
x = self.norm2(x)
|
| 481 |
+
ffn_out = self.ffn(x)
|
| 482 |
+
x = residual + ffn_out
|
| 483 |
+
return x
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
class DualHead(nn.Module):
|
| 487 |
+
def __init__(self, s1_bits, s2_bits, d_model):
|
| 488 |
+
super().__init__()
|
| 489 |
+
self.vocab_s1 = 2 ** s1_bits
|
| 490 |
+
self.vocab_s2 = 2 ** s2_bits
|
| 491 |
+
self.proj_s1 = nn.Linear(d_model, self.vocab_s1)
|
| 492 |
+
self.proj_s2 = nn.Linear(d_model, self.vocab_s2)
|
| 493 |
+
|
| 494 |
+
def compute_loss(self, s1_logits, s2_logits, s1_targets, s2_targets, padding_mask=None):
|
| 495 |
+
if padding_mask is not None:
|
| 496 |
+
valid_mask = (padding_mask == 0)
|
| 497 |
+
s1_logits = s1_logits[valid_mask]
|
| 498 |
+
s2_logits = s2_logits[valid_mask]
|
| 499 |
+
s1_targets = s1_targets[valid_mask]
|
| 500 |
+
s2_targets = s2_targets[valid_mask]
|
| 501 |
+
ce_s1 = F.cross_entropy(s1_logits, s1_targets)
|
| 502 |
+
ce_s2 = F.cross_entropy(s2_logits, s2_targets)
|
| 503 |
+
else:
|
| 504 |
+
ce_s1 = F.cross_entropy(s1_logits.reshape(-1, self.vocab_s1), s1_targets.reshape(-1))
|
| 505 |
+
ce_s2 = F.cross_entropy(s2_logits.reshape(-1, self.vocab_s2), s2_targets.reshape(-1))
|
| 506 |
+
ce_loss = (ce_s1 + ce_s2) / 2
|
| 507 |
+
return ce_loss, ce_s1, ce_s2
|
| 508 |
+
|
| 509 |
+
def forward(self, x):
|
| 510 |
+
return self.proj_s1(x)
|
| 511 |
+
|
| 512 |
+
def cond_forward(self, x2):
|
| 513 |
+
return self.proj_s2(x2)
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
class FixedEmbedding(nn.Module):
|
| 517 |
+
def __init__(self, c_in, d_model):
|
| 518 |
+
super(FixedEmbedding, self).__init__()
|
| 519 |
+
|
| 520 |
+
w = torch.zeros(c_in, d_model).float()
|
| 521 |
+
w.require_grad = False
|
| 522 |
+
|
| 523 |
+
position = torch.arange(0, c_in).float().unsqueeze(1)
|
| 524 |
+
div_term = (torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model)).exp()
|
| 525 |
+
|
| 526 |
+
w[:, 0::2] = torch.sin(position * div_term)
|
| 527 |
+
w[:, 1::2] = torch.cos(position * div_term)
|
| 528 |
+
|
| 529 |
+
self.emb = nn.Embedding(c_in, d_model)
|
| 530 |
+
self.emb.weight = nn.Parameter(w, requires_grad=False)
|
| 531 |
+
|
| 532 |
+
def forward(self, x):
|
| 533 |
+
return self.emb(x).detach()
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
class TemporalEmbedding(nn.Module):
|
| 537 |
+
def __init__(self, d_model, learn_pe):
|
| 538 |
+
super(TemporalEmbedding, self).__init__()
|
| 539 |
+
|
| 540 |
+
minute_size = 60
|
| 541 |
+
hour_size = 24
|
| 542 |
+
weekday_size = 7
|
| 543 |
+
day_size = 32
|
| 544 |
+
month_size = 13
|
| 545 |
+
|
| 546 |
+
Embed = FixedEmbedding if not learn_pe else nn.Embedding
|
| 547 |
+
self.minute_embed = Embed(minute_size, d_model)
|
| 548 |
+
self.hour_embed = Embed(hour_size, d_model)
|
| 549 |
+
self.weekday_embed = Embed(weekday_size, d_model)
|
| 550 |
+
self.day_embed = Embed(day_size, d_model)
|
| 551 |
+
self.month_embed = Embed(month_size, d_model)
|
| 552 |
+
|
| 553 |
+
def forward(self, x):
|
| 554 |
+
x = x.long()
|
| 555 |
+
|
| 556 |
+
minute_x = self.minute_embed(x[:, :, 0])
|
| 557 |
+
hour_x = self.hour_embed(x[:, :, 1])
|
| 558 |
+
weekday_x = self.weekday_embed(x[:, :, 2])
|
| 559 |
+
day_x = self.day_embed(x[:, :, 3])
|
| 560 |
+
month_x = self.month_embed(x[:, :, 4])
|
| 561 |
+
|
| 562 |
+
return hour_x + weekday_x + day_x + month_x + minute_x
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
|
Kronos-master/requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy
|
| 2 |
+
pandas
|
| 3 |
+
torch>=2.0.0
|
| 4 |
+
|
| 5 |
+
einops==0.8.1
|
| 6 |
+
huggingface_hub==0.33.1
|
| 7 |
+
matplotlib==3.9.3
|
| 8 |
+
pandas==2.2.2
|
| 9 |
+
tqdm==4.67.1
|
| 10 |
+
safetensors==0.6.2
|
README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Kronos AI Analysis
|
| 2 |
+
|
| 3 |
+
Kronos AI Analysis is a FastAPI + Lightweight Charts application for:
|
| 4 |
+
|
| 5 |
+
- loading market data from multiple providers with automatic fallback
|
| 6 |
+
- calculating technical indicators
|
| 7 |
+
- generating AI forecasts with Kronos
|
| 8 |
+
- serving the web UI directly from the backend
|
| 9 |
+
|
| 10 |
+
## Runtime Requirements
|
| 11 |
+
|
| 12 |
+
- Windows with Python 3.11 installed
|
| 13 |
+
- Internet access for live market data
|
| 14 |
+
- Internet access on first Kronos model load if the model is not already cached locally
|
| 15 |
+
|
| 16 |
+
## Project Layout
|
| 17 |
+
|
| 18 |
+
```text
|
| 19 |
+
Kronos_AI_Analysis/
|
| 20 |
+
|-- backend/
|
| 21 |
+
| |-- main.py
|
| 22 |
+
| `-- launcher.py
|
| 23 |
+
|-- frontend/
|
| 24 |
+
| `-- index.html
|
| 25 |
+
|-- Kronos-master/
|
| 26 |
+
| `-- model/
|
| 27 |
+
|-- dist/
|
| 28 |
+
| `-- SuperAI_Analysis.exe
|
| 29 |
+
|-- requirements.txt
|
| 30 |
+
`-- run_server.bat
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
## Recommended Start
|
| 34 |
+
|
| 35 |
+
Run:
|
| 36 |
+
|
| 37 |
+
```bat
|
| 38 |
+
run_server.bat
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
The startup script now:
|
| 42 |
+
|
| 43 |
+
1. checks Python 3.11
|
| 44 |
+
2. repairs a broken virtual environment if needed
|
| 45 |
+
3. installs the full runtime dependency set
|
| 46 |
+
4. verifies Kronos can be imported
|
| 47 |
+
5. starts the FastAPI server on `http://127.0.0.1:8000`
|
| 48 |
+
|
| 49 |
+
## Manual Start
|
| 50 |
+
|
| 51 |
+
```bat
|
| 52 |
+
py -3.11 -m venv venv
|
| 53 |
+
venv\Scripts\python.exe -m pip install -r requirements.txt
|
| 54 |
+
venv\Scripts\python.exe -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## Important Notes
|
| 58 |
+
|
| 59 |
+
- This project uses Kronos, not TimesFM.
|
| 60 |
+
- Kronos source code is loaded from `Kronos-master`.
|
| 61 |
+
- Kronos weights are loaded from Hugging Face via:
|
| 62 |
+
- `NeoQuasar/Kronos-base`
|
| 63 |
+
- `NeoQuasar/Kronos-Tokenizer-base`
|
| 64 |
+
- The first AI forecast can take longer because the model may warm up or download into cache.
|
| 65 |
+
|
| 66 |
+
## Key Endpoints
|
| 67 |
+
|
| 68 |
+
- `GET /api/health`
|
| 69 |
+
- `GET /api/symbols`
|
| 70 |
+
- `GET /api/historical/{symbol}`
|
| 71 |
+
- `GET /api/indicators/{symbol}`
|
| 72 |
+
- `GET /api/forecast/{symbol}`
|
| 73 |
+
- `GET /api/market-status`
|
| 74 |
+
- `GET /api/crypto/market`
|
| 75 |
+
- `WS /ws/price/{symbol}`
|
| 76 |
+
|
| 77 |
+
## Health Expectations
|
| 78 |
+
|
| 79 |
+
When the system is healthy:
|
| 80 |
+
|
| 81 |
+
- `/api/health` returns `status: online`
|
| 82 |
+
- `kronos.available` is `true`
|
| 83 |
+
- `kronos.loaded` becomes `true` after warmup or after the first forecast
|
| 84 |
+
- the frontend loads from the same backend origin
|
| 85 |
+
|
| 86 |
+
## Troubleshooting
|
| 87 |
+
|
| 88 |
+
- If Python 3.11 is missing, install it first.
|
| 89 |
+
- If a previous `venv` was copied from another machine, `run_server.bat` rebuilds it.
|
| 90 |
+
- If forecasts fail, check `/api/health` and confirm Kronos reports as available and loaded.
|
| 91 |
+
- If market data fails, check network reachability to Binance, Bybit, CoinGecko, Twelve Data, Finnhub, and Yahoo Finance.
|
SOURCES_AND_SYMBOLS.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NGUỒN DỮ LIỆU MIỄN PHÍ — XÁC NHẬN & PHÂN LOẠI MÃ GIAO DỊCH
|
| 2 |
+
# Free Data Sources — Confirmed & Symbol Classification v4.0
|
| 3 |
+
|
| 4 |
+
═══════════════════════════════════════════════════════════════════════
|
| 5 |
+
## A. BẢNG NGUỒN DỮ LIỆU MIỄN PHÍ (Confirmed Free Sources)
|
| 6 |
+
═══════════════════════════════════════════════════════════════════════
|
| 7 |
+
|
| 8 |
+
| Nguồn | URL cơ sở | API Key? | Giới hạn miễn phí | Tốt nhất cho |
|
| 9 |
+
|--------------|----------------------------------------|----------|--------------------------|---------------------------|
|
| 10 |
+
| Binance | api.binance.com/api/v3 | Không | 1200 req/min | Crypto OHLCV (tốt nhất) |
|
| 11 |
+
| Bybit V5 | api.bybit.com/v5/market | Không | 120 req/min | Crypto backup |
|
| 12 |
+
| CoinGecko | api.coingecko.com/api/v3 | Không | 30 req/min | Crypto OHLCV + market cap |
|
| 13 |
+
| yfinance | (scrapes finance.yahoo.com) | Không | Không giới hạn chính thức| Stocks, ETF, Forex, Chỉ số|
|
| 14 |
+
| Finnhub | finnhub.io/api/v1 | Có (free)| 60 req/min | Forex (OANDA), Stock, Crypto|
|
| 15 |
+
| Twelvedata | api.twelvedata.com | Có (free)| 800 credits/ngày, 8/phút | Forex, Stock, Crypto |
|
| 16 |
+
| Alpha Vantage| alphavantage.co/query | Có (free)| 25 req/ngày | Forex fallback (hạn chế) |
|
| 17 |
+
| FRED | fred.stlouisfed.org/graph/fredgraph | Không | Không giới hạn | Macro (lãi suất, DXY...) |
|
| 18 |
+
|
| 19 |
+
### Ưu tiên nguồn theo loại tài sản:
|
| 20 |
+
- **Crypto**: Binance → Bybit → CoinGecko → yfinance → Finnhub
|
| 21 |
+
- **Forex**: Twelvedata → Finnhub (OANDA) → yfinance
|
| 22 |
+
- **Kim loại**: yfinance (futures =F) → Twelvedata → Finnhub
|
| 23 |
+
- **Năng lượng**: yfinance (futures =F) → Twelvedata → Finnhub
|
| 24 |
+
- **Nông sản**: yfinance (futures =F) → Twelvedata
|
| 25 |
+
- **Chỉ số**: yfinance (^SYMBOL) → Twelvedata → Finnhub
|
| 26 |
+
- **Cổ phiếu Mỹ**: yfinance → Finnhub → Twelvedata
|
| 27 |
+
- **Cổ phiếu VN**: yfinance (.VN suffix)
|
| 28 |
+
- **Trái phiếu**: yfinance (^TNX, ^TYX...)
|
| 29 |
+
|
| 30 |
+
═══════════════════════════════════════════════════════════════════════
|
| 31 |
+
## B. PHÂN LOẠI MÃ GIAO DỊCH CHUẨN
|
| 32 |
+
═══════════════════════════════════════════════════════════════════════
|
| 33 |
+
|
| 34 |
+
### 1. KIM LOẠI (Metals) — 13 mã
|
| 35 |
+
| Mã | Tên VN | Ticker yfinance | Nguồn tốt nhất |
|
| 36 |
+
|--------------|---------------------|-----------------|---------------------|
|
| 37 |
+
| XAUUSD | Vàng | GC=F | Twelvedata, Finnhub |
|
| 38 |
+
| XAGUSD | Bạc | SI=F | Twelvedata, Finnhub |
|
| 39 |
+
| XPTUSD | Bạch kim | PL=F | Twelvedata, yfinance |
|
| 40 |
+
| XPDUSD | Palladium | PA=F | Twelvedata, yfinance |
|
| 41 |
+
| COPPER | Đồng COMEX | HG=F | yfinance, Twelvedata |
|
| 42 |
+
| COPPER_LME | Đồng LME | HG=F | Twelvedata |
|
| 43 |
+
| ALUMINUM | Nhôm COMEX | ALI=F | yfinance |
|
| 44 |
+
| ALUMINUM_LME | Nhôm LME | LMAHDS03 | Twelvedata |
|
| 45 |
+
| NICKEL_LME | Niken LME | NI1 | Twelvedata |
|
| 46 |
+
| ZINC_LME | Kẽm LME | ZN1 | Twelvedata |
|
| 47 |
+
| IRON_ORE | Quặng sắt | TIO=F | yfinance |
|
| 48 |
+
| STEEL_HRC | Thép cuộn cán nóng | HRC=F | yfinance |
|
| 49 |
+
| LITHIUM | Lithium (proxy ALB) | ALB | yfinance |
|
| 50 |
+
|
| 51 |
+
### 2. NĂNG LƯỢNG (Energy) — 8 mã
|
| 52 |
+
| Mã | Tên VN | Ticker yfinance | Ghi chú |
|
| 53 |
+
|--------------|---------------------|-----------------|----------------------|
|
| 54 |
+
| WTI | Dầu thô WTI | CL=F | Chuẩn Mỹ |
|
| 55 |
+
| BRENT | Dầu Brent | BZ=F | Chuẩn quốc tế |
|
| 56 |
+
| NATURAL_GAS | Khí tự nhiên | NG=F | |
|
| 57 |
+
| GASOLINE_RBOB| Xăng RBOB | RB=F | |
|
| 58 |
+
| HEATING_OIL | Dầu sưởi | HO=F | |
|
| 59 |
+
| LNG | Khí hoá lỏng proxy | LNG | Cổ phiếu Cheniere |
|
| 60 |
+
| COAL | Than Newcastle | MTF=F | |
|
| 61 |
+
| URANIUM | Uranium | UX=F | |
|
| 62 |
+
|
| 63 |
+
### 3. NÔNG SẢN (Agricultural) — 7 mã
|
| 64 |
+
| Mã | Tên VN | Ticker yfinance |
|
| 65 |
+
|--------------|---------------------|-----------------|
|
| 66 |
+
| CORN | Ngô | ZC=F |
|
| 67 |
+
| WHEAT | Lúa mì | ZW=F |
|
| 68 |
+
| SOYBEAN | Đậu tương | ZS=F |
|
| 69 |
+
| SOYBEAN_OIL | Dầu đậu tương | ZL=F |
|
| 70 |
+
| SOYBEAN_MEAL | Khô đậu tương | ZM=F |
|
| 71 |
+
| RICE | Gạo thô | ZR=F |
|
| 72 |
+
| OATS | Yến mạch | ZO=F |
|
| 73 |
+
|
| 74 |
+
### 4. NGUYÊN LIỆU CÔNG NGHIỆP (Soft Commodities) — 10 mã
|
| 75 |
+
| Mã | Tên VN | Ticker yfinance |
|
| 76 |
+
|----------------|---------------------|-----------------|
|
| 77 |
+
| COFFEE_ARABICA | Cà phê Arabica | KC=F |
|
| 78 |
+
| COFFEE_ROBUSTA | Cà phê Robusta | RC=F |
|
| 79 |
+
| COCOA | Ca cao | CC=F |
|
| 80 |
+
| SUGAR_11 | Đường 11 | SB=F |
|
| 81 |
+
| WHITE_SUGAR | Đường trắng | LSW=F |
|
| 82 |
+
| COTTON | Bông | CT=F |
|
| 83 |
+
| ORANGE_JUICE | Nước cam | OJ=F |
|
| 84 |
+
| LUMBER | Gỗ xẻ | LBS=F |
|
| 85 |
+
| RUBBER_RSS3 | Cao su RSS3 | JRU=F |
|
| 86 |
+
| PALM_OIL | Dầu cọ thô | FCPO=F |
|
| 87 |
+
|
| 88 |
+
### 5. CRYPTO — 25 mã (Top by market cap)
|
| 89 |
+
| Mã | Tên | Binance | CoinGecko ID |
|
| 90 |
+
|----------|----------------|--------------|---------------------|
|
| 91 |
+
| BTCUSD | Bitcoin | BTCUSDT | bitcoin |
|
| 92 |
+
| ETHUSD | Ethereum | ETHUSDT | ethereum |
|
| 93 |
+
| BNBUSD | BNB | BNBUSDT | binancecoin |
|
| 94 |
+
| SOLUSD | Solana | SOLUSDT | solana |
|
| 95 |
+
| XRPUSD | Ripple | XRPUSDT | ripple |
|
| 96 |
+
| ADAUSD | Cardano | ADAUSDT | cardano |
|
| 97 |
+
| AVAXUSD | Avalanche | AVAXUSDT | avalanche-2 |
|
| 98 |
+
| DOTUSD | Polkadot | DOTUSDT | polkadot |
|
| 99 |
+
| MATICUSD | Polygon | MATICUSDT | matic-network |
|
| 100 |
+
| LINKUSD | Chainlink | LINKUSDT | chainlink |
|
| 101 |
+
| LTCUSD | Litecoin | LTCUSDT | litecoin |
|
| 102 |
+
| UNIUSD | Uniswap | UNIUSDT | uniswap |
|
| 103 |
+
| ATOMUSD | Cosmos | ATOMUSDT | cosmos |
|
| 104 |
+
| NEARUSD | NEAR Protocol | NEARUSDT | near |
|
| 105 |
+
| APTUSD | Aptos | APTUSDT | aptos |
|
| 106 |
+
| SUIUSD | Sui | SUIUSDT | sui |
|
| 107 |
+
| ARBUSD | Arbitrum | ARBUSDT | arbitrum |
|
| 108 |
+
| OPUSD | Optimism | OPUSDT | optimism |
|
| 109 |
+
| INJUSD | Injective | INJUSDT | injective-protocol |
|
| 110 |
+
| TONUSD | Toncoin | TONUSDT | the-open-network |
|
| 111 |
+
| TRXUSD | Tron | TRXUSDT | tron |
|
| 112 |
+
| XMRUSD | Monero | (Bybit only) | monero |
|
| 113 |
+
| DOGEUSD | Dogecoin | DOGEUSDT | dogecoin |
|
| 114 |
+
| SHIBAUSD | Shiba Inu | SHIBUSDT | shiba-inu |
|
| 115 |
+
| PEPE | PEPE Coin | PEPEUSDT | pepe |
|
| 116 |
+
|
| 117 |
+
### 6. CẶP TIỀN (Forex) — 19 mã
|
| 118 |
+
| Mã | Tên | Twelvedata | Finnhub | yfinance |
|
| 119 |
+
|----------|---------------|--------------|------------------|--------------|
|
| 120 |
+
| DXY | Chỉ số USD | — | — | DX-Y.NYB |
|
| 121 |
+
| EURUSD | EUR/USD | EUR/USD | OANDA:EUR_USD | EURUSD=X |
|
| 122 |
+
| GBPUSD | GBP/USD | GBP/USD | OANDA:GBP_USD | GBPUSD=X |
|
| 123 |
+
| USDJPY | USD/JPY | USD/JPY | OANDA:USD_JPY | JPY=X |
|
| 124 |
+
| USDCHF | USD/CHF | USD/CHF | OANDA:USD_CHF | CHF=X |
|
| 125 |
+
| AUDUSD | AUD/USD | AUD/USD | OANDA:AUD_USD | AUDUSD=X |
|
| 126 |
+
| USDCAD | USD/CAD | USD/CAD | OANDA:USD_CAD | CAD=X |
|
| 127 |
+
| NZDUSD | NZD/USD | NZD/USD | OANDA:NZD_USD | NZDUSD=X |
|
| 128 |
+
| GBPJPY | GBP/JPY | GBP/JPY | OANDA:GBP_JPY | GBPJPY=X |
|
| 129 |
+
| EURJPY | EUR/JPY | EUR/JPY | OANDA:EUR_JPY | EURJPY=X |
|
| 130 |
+
| EURGBP | EUR/GBP | EUR/GBP | OANDA:EUR_GBP | EURGBP=X |
|
| 131 |
+
| CADCHF | CAD/CHF | CAD/CHF | OANDA:CAD_CHF | CADCHF=X |
|
| 132 |
+
| AUDNZD | AUD/NZD | AUD/NZD | OANDA:AUD_NZD | AUDNZD=X |
|
| 133 |
+
| USDVND | USD/VND | — | — | VND=X |
|
| 134 |
+
| USDHKD | USD/HKD | USD/HKD | — | HKD=X |
|
| 135 |
+
| USDSGD | USD/SGD | USD/SGD | — | SGD=X |
|
| 136 |
+
| USDCNY | USD/CNY | USD/CNH | — | CNY=X |
|
| 137 |
+
| USDINR | USD/INR | — | — | INR=X |
|
| 138 |
+
| USDBRL | USD/BRL | — | — | BRL=X |
|
| 139 |
+
|
| 140 |
+
### 7. CHỈ SỐ (Stock Indices) — 20 mã
|
| 141 |
+
| Mã | Tên | yfinance | Ghi chú |
|
| 142 |
+
|------------|----------------------|------------|-----------------|
|
| 143 |
+
| SP500 | S&P 500 | ^GSPC | Chuẩn Mỹ |
|
| 144 |
+
| NASDAQ | Nasdaq Composite | ^IXIC | |
|
| 145 |
+
| NASDAQ100 | Nasdaq 100 | ^NDX | |
|
| 146 |
+
| DOW30 | Dow Jones 30 | ^DJI | |
|
| 147 |
+
| RUSSELL2000| Russell 2000 | ^RUT | Small-cap Mỹ |
|
| 148 |
+
| VIX | VIX Fear Index | ^VIX | |
|
| 149 |
+
| VNINDEX | VN-Index (HOSE) | ^VNINDEX | Việt Nam |
|
| 150 |
+
| HNX30 | HNX 30 | ^HNX30 | Việt Nam |
|
| 151 |
+
| UK100 | FTSE 100 | ^FTSE | Anh |
|
| 152 |
+
| DAX40 | DAX 40 | ^GDAXI | Đức |
|
| 153 |
+
| EU50 | Euro Stoxx 50 | ^STOXX50E | Châu Âu |
|
| 154 |
+
| CAC40 | CAC 40 | ^FCHI | Pháp |
|
| 155 |
+
| NIKKEI225 | Nikkei 225 | ^N225 | Nhật Bản |
|
| 156 |
+
| HSI | Hang Seng | ^HSI | Hong Kong |
|
| 157 |
+
| SSE50 | SSE 50 | 000016.SS | Trung Quốc |
|
| 158 |
+
| CSI300 | CSI 300 | 000300.SS | Trung Quốc |
|
| 159 |
+
| ASX200 | ASX 200 | ^AXJO | Úc |
|
| 160 |
+
| SENSEX | BSE Sensex | ^BSESN | Ấn Độ |
|
| 161 |
+
| KOSPI | KOSPI | ^KS11 | Hàn Quốc |
|
| 162 |
+
| SGX | Straits Times | ^STI | Singapore |
|
| 163 |
+
|
| 164 |
+
### 8. CỔ PHIẾU MỸ (US Stocks) — 21 mã
|
| 165 |
+
AAPL, MSFT, NVDA, GOOGL, AMZN, META, TSLA, AVGO, JPM, V, MA,
|
| 166 |
+
XOM, WMT, BAC, GS, AMD, INTC, NFLX, DIS, COIN, MSTR
|
| 167 |
+
→ Tất cả dùng yfinance + Finnhub + Twelvedata (ticker trực tiếp)
|
| 168 |
+
|
| 169 |
+
### 9. CỔ PHIẾU VIỆT NAM (VN Stocks) — 25 mã (yfinance .VN suffix)
|
| 170 |
+
Ngân hàng: VCB, BID, CTG, TCB, MBB, VPB, ACB, HDB, SHB, STB
|
| 171 |
+
Công nghệ: FPT
|
| 172 |
+
Bất động sản: VIC, VHM
|
| 173 |
+
FMCG: VNM, MSN, SAB
|
| 174 |
+
Năng lượng/CN: GAS, PLX, POW, HPG, DGC
|
| 175 |
+
Tiêu dùng: MWG
|
| 176 |
+
Chứng khoán: SSI, VND, HCM
|
| 177 |
+
|
| 178 |
+
### 10. TRÁI PHIẾU & LÃI SUẤT (Bonds & Rates) — 5 mã
|
| 179 |
+
| Mã | Tên | yfinance | Ghi chú |
|
| 180 |
+
|--------|----------------------|--------------|------------------------|
|
| 181 |
+
| US10Y | Trái phiếu Mỹ 10 năm | ^TNX | Chuẩn lãi suất toàn cầu|
|
| 182 |
+
| US02Y | Trái phiếu Mỹ 2 năm | ^IRX | |
|
| 183 |
+
| US30Y | Trái phiếu Mỹ 30 năm | ^TYX | |
|
| 184 |
+
| DE10Y | Bund Đức 10 năm | ^DE10YT=RR | |
|
| 185 |
+
| JP10Y | JGB Nhật 10 năm | ^JP10YT=RR | |
|
| 186 |
+
|
| 187 |
+
═══════════════════════════════════════════════════════════════════════
|
| 188 |
+
## C. API ENDPOINTS v4.0
|
| 189 |
+
═══════════════════════════════════════════════════════════════════════
|
| 190 |
+
|
| 191 |
+
### Dữ liệu lịch sử
|
| 192 |
+
GET /api/symbols → Danh sách tất cả mã (có thể lọc theo category)
|
| 193 |
+
GET /api/search?q=gold → Tìm kiếm mờ (fuzzy search)
|
| 194 |
+
GET /api/historical/{symbol} → Nến OHLCV (interval, limit)
|
| 195 |
+
GET /api/indicators/{symbol} → Chỉ báo kỹ thuật (RSI, MACD, BB, EMA, ATR)
|
| 196 |
+
|
| 197 |
+
### Giá thực tế
|
| 198 |
+
GET /api/ticker/{symbol} → Giá cuối + 24h stats
|
| 199 |
+
POST /api/watchlist/tickers → Batch ticker cho danh mục
|
| 200 |
+
GET /api/crypto/market → Top N crypto theo vốn hoá (CoinGecko)
|
| 201 |
+
WS /ws/price/{symbol} → WebSocket stream giá thực tế (5s)
|
| 202 |
+
|
| 203 |
+
### AI Forecast
|
| 204 |
+
GET /api/forecast/{symbol} → Dự báo Kronos (horizon, quantiles 10/50/90)
|
| 205 |
+
|
| 206 |
+
### Thị trường
|
| 207 |
+
GET /api/market-status → Trạng thái mở/đóng cửa các sàn thế giới
|
| 208 |
+
|
| 209 |
+
### Cache & Admin
|
| 210 |
+
POST /api/switch → Xoá cache khi đổi symbol/interval
|
| 211 |
+
DELETE /api/cache/{sym}/{iv} → Xoá cache cụ thể
|
| 212 |
+
DELETE /api/cache → Xoá tất cả cache
|
| 213 |
+
GET /api/cache/stats → Thống kê cache
|
| 214 |
+
GET /api/health → Trạng thái server
|
| 215 |
+
|
| 216 |
+
═══════════════════════════════════════════════════════════════════════
|
| 217 |
+
## D. CÀI ĐẶT & CHẠY
|
| 218 |
+
═══════════════════════════════════════════════════════════════════════
|
| 219 |
+
|
| 220 |
+
# 1. Cài đặt dependencies
|
| 221 |
+
pip install -r requirements.txt
|
| 222 |
+
|
| 223 |
+
# 2. Cài pytz (cần cho market-status)
|
| 224 |
+
pip install pytz
|
| 225 |
+
|
| 226 |
+
# 3. Chạy server
|
| 227 |
+
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
| 228 |
+
|
| 229 |
+
# 4. Xem API docs
|
| 230 |
+
http://localhost:8000/docs
|
| 231 |
+
|
| 232 |
+
# 5. Kiểm tra health
|
| 233 |
+
curl http://localhost:8000/api/health
|
| 234 |
+
|
| 235 |
+
# 6. Test lấy giá vàng
|
| 236 |
+
curl "http://localhost:8000/api/historical/XAUUSD?interval=1h&limit=100"
|
| 237 |
+
|
| 238 |
+
# 7. Test chỉ báo kỹ thuật BTC
|
| 239 |
+
curl "http://localhost:8000/api/indicators/BTCUSD?interval=4h"
|
| 240 |
+
|
| 241 |
+
# 8. Test thị trường đang mở
|
| 242 |
+
curl http://localhost:8000/api/market-status
|
app.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import uvicorn
|
| 3 |
+
from backend.main import app
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
# Hugging Face Spaces yêu cầu ứng dụng phải chạy trên cổng 7860
|
| 7 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
backend/launcher.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
import webbrowser
|
| 5 |
+
import threading
|
| 6 |
+
import uvicorn
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
# Set up professional logging
|
| 10 |
+
logging.basicConfig(
|
| 11 |
+
level=logging.INFO,
|
| 12 |
+
format="%(asctime)s | %(levelname)s | %(message)s",
|
| 13 |
+
)
|
| 14 |
+
logger = logging.getLogger("super-ai-launcher")
|
| 15 |
+
|
| 16 |
+
# ASCII Art Header
|
| 17 |
+
BANNER = r"""
|
| 18 |
+
____ _ _ ____ _____ ____ _ ___ _ _ _ _ _ __ ______ ___ ____
|
| 19 |
+
/ ___|| | | | _ \| ____| _ \ / \ |_ _| / \ | \ | | / \ | | \ \ / / ___|_ _/ ___|
|
| 20 |
+
\___ \| | | | |_) | _| | |_) | / _ \ | | / _ \ | \| | / _ \ | | \ V /\___ \ | \___ \
|
| 21 |
+
___) | |_| | __/| |___| _ < / ___ \ | | / ___ \| |\ |/ ___ \| |___| | ___) | | ___) |
|
| 22 |
+
|____/ \___/|_| |_____|_| \_\/_/ \_\___| /_/ \_\_| \_/_/ \_\_____|_| |____/___|____/
|
| 23 |
+
|
| 24 |
+
══════════════════════════════════════════════════════════════════════════════════════════════
|
| 25 |
+
TRADING INTELLIGENT TERMINAL - AI PHÂN TÍCH BIỂU ĐỒ NẾN SỐ 1 THẾ GIỚI
|
| 26 |
+
══════════════════════════════════════════════════════════════════════════════════════════════
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def print_banner():
|
| 30 |
+
# Clear console for a clean startup
|
| 31 |
+
os.system('cls' if os.name == 'nt' else 'clear')
|
| 32 |
+
print("\033[96m" + BANNER + "\033[0m")
|
| 33 |
+
print(" [*] Đang khởi động hệ thống phân tích AI...")
|
| 34 |
+
print(" [*] Đang nạp cơ sở dữ liệu thị trường...")
|
| 35 |
+
print(" [*] Hệ thống sẽ tự động mở trình duyệt sau khi hoàn tất.")
|
| 36 |
+
print(" ══════════════════════════════════════════════════════════════════════════════════════════════\n")
|
| 37 |
+
|
| 38 |
+
# Import the FastAPI app
|
| 39 |
+
try:
|
| 40 |
+
from main import app
|
| 41 |
+
except ImportError:
|
| 42 |
+
# If running from within the backend folder
|
| 43 |
+
sys.path.append(os.path.dirname(__file__))
|
| 44 |
+
from main import app
|
| 45 |
+
|
| 46 |
+
def open_browser():
|
| 47 |
+
"""Wait for the server to start and then open the browser."""
|
| 48 |
+
# Give the server a few seconds to initialize
|
| 49 |
+
time.sleep(2.5)
|
| 50 |
+
url = "http://127.0.0.1:8000"
|
| 51 |
+
logger.info(f"Kết nối thành công! Đang mở bảng điều khiển tại {url}...")
|
| 52 |
+
webbrowser.open(url)
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
print_banner()
|
| 56 |
+
|
| 57 |
+
# Start the browser opener in a separate thread
|
| 58 |
+
threading.Thread(target=open_browser, daemon=True).start()
|
| 59 |
+
|
| 60 |
+
# Run the uvicorn server
|
| 61 |
+
logger.info("Khởi động Backend Core...")
|
| 62 |
+
try:
|
| 63 |
+
# Hide the detailed uvicorn logs for a cleaner professional feel,
|
| 64 |
+
# or keep them for transparency. Let's use 'warning' level for uvicorn
|
| 65 |
+
# but keep our own logs at 'info'.
|
| 66 |
+
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="warning")
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Lỗi khởi động hệ thống: {e}")
|
| 69 |
+
print("\n [!] Có lỗi xảy ra trong quá trình khởi động.")
|
| 70 |
+
print(" [!] Nhấn Enter để xem chi tiết lỗi và thoát...")
|
| 71 |
+
input()
|
backend/main.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/ai_analysis_bg.png
ADDED
|
Git LFS Details
|
frontend/index.html
ADDED
|
@@ -0,0 +1,2217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Super AI Analysis · AI phân tích biểu đồ nến số 1 thế giới hiện tại được nghiên cứu bởi đại học Thanh Hoa!
|
| 8 |
+
</title>
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link
|
| 12 |
+
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Barlow:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap"
|
| 13 |
+
rel="stylesheet">
|
| 14 |
+
<script src="https://unpkg.com/lightweight-charts@4.2.2/dist/lightweight-charts.standalone.production.js"></script>
|
| 15 |
+
<style>
|
| 16 |
+
/* ═══════════════════════════════════════════════
|
| 17 |
+
DESIGN TOKENS
|
| 18 |
+
═══════════════════════════════════════════════ */
|
| 19 |
+
:root {
|
| 20 |
+
/* Base surfaces */
|
| 21 |
+
--bg-base: #02060c;
|
| 22 |
+
--bg-depth: #010409;
|
| 23 |
+
--bg-glass: rgba(2, 8, 16, 0.75);
|
| 24 |
+
--bg-glass-light: rgba(4, 12, 24, 0.55);
|
| 25 |
+
--bg-control: rgba(6, 16, 36, 0.85);
|
| 26 |
+
--bg-control-hov: rgba(12, 28, 58, 0.95);
|
| 27 |
+
--bg-active: rgba(34, 211, 238, 0.12);
|
| 28 |
+
|
| 29 |
+
/* Borders */
|
| 30 |
+
--bdr-dim: rgba(40, 80, 140, 0.15);
|
| 31 |
+
--bdr-base: rgba(50, 95, 165, 0.25);
|
| 32 |
+
--bdr-muted: rgba(50, 100, 175, 0.38);
|
| 33 |
+
--bdr-accent: rgba(34, 211, 238, 0.45);
|
| 34 |
+
|
| 35 |
+
/* Text hierarchy */
|
| 36 |
+
--txt-bright: #ffffff;
|
| 37 |
+
--txt-primary: #f1f5f9; /* Boosted from #cbd5e1 */
|
| 38 |
+
--txt-secondary: #cbd5e1; /* Boosted from #94a3b8 */
|
| 39 |
+
--txt-muted: #64748b; /* Boosted from #475569 */
|
| 40 |
+
|
| 41 |
+
/* Accent — cold cyan */
|
| 42 |
+
--accent: #22d3ee;
|
| 43 |
+
--accent-lo: rgba(34, 211, 238, 0.08);
|
| 44 |
+
--accent-mid: rgba(34, 211, 238, 0.25);
|
| 45 |
+
--accent-glow: rgba(34, 211, 238, 0.40);
|
| 46 |
+
|
| 47 |
+
/* Semantic */
|
| 48 |
+
--ok: #2dd4bf;
|
| 49 |
+
--err: #fb7185;
|
| 50 |
+
--warn: #fbbf24;
|
| 51 |
+
|
| 52 |
+
/* Chart series */
|
| 53 |
+
--bull: #2dd4bf;
|
| 54 |
+
--bear: #fb7185;
|
| 55 |
+
|
| 56 |
+
/* Typography */
|
| 57 |
+
--ff-display: 'Barlow', 'Chakra Petch', sans-serif;
|
| 58 |
+
--ff-ui: 'Barlow', system-ui, sans-serif;
|
| 59 |
+
--ff-mono: 'Space Mono', monospace;
|
| 60 |
+
|
| 61 |
+
/* Layout */
|
| 62 |
+
--header-h: 60px;
|
| 63 |
+
--sidebar-w: 280px;
|
| 64 |
+
--radius: 8px;
|
| 65 |
+
--radius-lg: 18px;
|
| 66 |
+
|
| 67 |
+
/* Dashboard specificity */
|
| 68 |
+
--glass-cyan: rgba(6, 18, 42, 0.85);
|
| 69 |
+
--neon-cyan: #22d3ee;
|
| 70 |
+
--neon-pink: #f472b6;
|
| 71 |
+
--neon-green: #34d399;
|
| 72 |
+
--neon-blue: #3b82f6;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* ═══════════════════════════════════════════════
|
| 76 |
+
RESET
|
| 77 |
+
═══════════════════════════════════════════════ */
|
| 78 |
+
*,
|
| 79 |
+
*::before,
|
| 80 |
+
*::after {
|
| 81 |
+
box-sizing: border-box;
|
| 82 |
+
margin: 0;
|
| 83 |
+
padding: 0;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
html,
|
| 87 |
+
body {
|
| 88 |
+
width: 100%;
|
| 89 |
+
height: 100%;
|
| 90 |
+
overflow: hidden;
|
| 91 |
+
background: var(--bg-base);
|
| 92 |
+
color: var(--txt-primary);
|
| 93 |
+
font-family: var(--ff-ui);
|
| 94 |
+
font-size: 13px;
|
| 95 |
+
-webkit-font-smoothing: antialiased;
|
| 96 |
+
-moz-osx-font-smoothing: grayscale;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* ═══════════════════════════════════════════════
|
| 100 |
+
BACKGROUND SYSTEM
|
| 101 |
+
Layers (bottom→top):
|
| 102 |
+
1. Solid base color
|
| 103 |
+
2. Radial centre-glow (very subtle)
|
| 104 |
+
3. Dot matrix
|
| 105 |
+
4. Corner nebulae (pseudo)
|
| 106 |
+
5. Vignette overlay
|
| 107 |
+
═══════════════════════════════════════════════ */
|
| 108 |
+
body {
|
| 109 |
+
background:
|
| 110 |
+
/* vignette */
|
| 111 |
+
radial-gradient(ellipse 130% 100% at 50% 50%,
|
| 112 |
+
transparent 55%, rgba(1, 4, 12, 0.92) 100%),
|
| 113 |
+
/* top-left ambient nebula */
|
| 114 |
+
radial-gradient(ellipse 60% 40% at -5% -5%,
|
| 115 |
+
rgba(16, 80, 180, 0.12) 0%, transparent 65%),
|
| 116 |
+
/* bottom-right ambient nebula */
|
| 117 |
+
radial-gradient(ellipse 55% 38% at 105% 105%,
|
| 118 |
+
rgba(0, 150, 180, 0.10) 0%, transparent 65%),
|
| 119 |
+
/* centre elevation */
|
| 120 |
+
radial-gradient(ellipse 100% 80% at 50% 20%,
|
| 121 |
+
rgba(6, 20, 50, 0.60) 0%, transparent 70%),
|
| 122 |
+
/* dot grid */
|
| 123 |
+
radial-gradient(circle, rgba(34, 211, 238, 0.055) 1px, transparent 1px),
|
| 124 |
+
/* solid base */
|
| 125 |
+
var(--bg-base);
|
| 126 |
+
background-size:
|
| 127 |
+
100% 100%,
|
| 128 |
+
100% 100%,
|
| 129 |
+
100% 100%,
|
| 130 |
+
100% 100%,
|
| 131 |
+
28px 28px,
|
| 132 |
+
100% 100%;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/* ═══════════════════════════════════════════════
|
| 136 |
+
APP SHELL
|
| 137 |
+
═══════════════════════════════════════════════ */
|
| 138 |
+
#app {
|
| 139 |
+
display: flex;
|
| 140 |
+
flex-direction: column;
|
| 141 |
+
width: 100%;
|
| 142 |
+
height: 100%;
|
| 143 |
+
position: relative;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.app-body {
|
| 147 |
+
display: flex;
|
| 148 |
+
flex: 1;
|
| 149 |
+
overflow: hidden;
|
| 150 |
+
position: relative;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/* ── SIDEBAR ────────────────────────────────── */
|
| 154 |
+
.sidebar {
|
| 155 |
+
width: var(--sidebar-w);
|
| 156 |
+
background: var(--bg-depth);
|
| 157 |
+
border-right: 1px solid var(--bdr-dim);
|
| 158 |
+
display: flex;
|
| 159 |
+
flex-direction: column;
|
| 160 |
+
flex-shrink: 0;
|
| 161 |
+
z-index: 15;
|
| 162 |
+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.sidebar.collapsed {
|
| 166 |
+
transform: translateX(-100%);
|
| 167 |
+
margin-right: calc(var(--sidebar-w) * -1);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* Floating Tab Trigger */
|
| 171 |
+
.sidebar-trigger {
|
| 172 |
+
position: absolute;
|
| 173 |
+
left: 0;
|
| 174 |
+
top: 50%;
|
| 175 |
+
transform: translateY(-50%);
|
| 176 |
+
width: 28px;
|
| 177 |
+
height: 80px;
|
| 178 |
+
background: var(--bg-depth);
|
| 179 |
+
border: 1px solid var(--bdr-accent);
|
| 180 |
+
border-left: none;
|
| 181 |
+
border-radius: 0 8px 8px 0;
|
| 182 |
+
display: none;
|
| 183 |
+
align-items: center;
|
| 184 |
+
justify-content: center;
|
| 185 |
+
cursor: pointer;
|
| 186 |
+
z-index: 20;
|
| 187 |
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 188 |
+
box-shadow: 4px 0 15px rgba(0, 0, 0, 0.5);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.sidebar-trigger:hover {
|
| 192 |
+
background: var(--bg-active);
|
| 193 |
+
width: 32px;
|
| 194 |
+
box-shadow: 0 0 20px var(--accent-glow);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.sidebar-trigger span {
|
| 198 |
+
writing-mode: vertical-lr;
|
| 199 |
+
transform: rotate(180deg);
|
| 200 |
+
font-size: 0.7rem;
|
| 201 |
+
font-weight: 700;
|
| 202 |
+
letter-spacing: 0.15em;
|
| 203 |
+
text-transform: uppercase;
|
| 204 |
+
color: var(--accent);
|
| 205 |
+
white-space: nowrap;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.sidebar-head {
|
| 209 |
+
height: 48px;
|
| 210 |
+
display: flex;
|
| 211 |
+
align-items: center;
|
| 212 |
+
justify-content: space-between;
|
| 213 |
+
padding: 0 16px;
|
| 214 |
+
border-bottom: 1px solid var(--bdr-dim);
|
| 215 |
+
background: rgba(255, 255, 255, 0.02);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.sidebar-title {
|
| 219 |
+
font-family: var(--ff-display);
|
| 220 |
+
font-size: 0.75rem;
|
| 221 |
+
font-weight: 700;
|
| 222 |
+
letter-spacing: 0.15em;
|
| 223 |
+
text-transform: uppercase;
|
| 224 |
+
color: var(--txt-primary);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.sidebar-content {
|
| 228 |
+
flex: 1;
|
| 229 |
+
overflow-y: auto;
|
| 230 |
+
padding: 12px;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/* Market Overview List */
|
| 234 |
+
.market-list {
|
| 235 |
+
display: flex;
|
| 236 |
+
flex-direction: column;
|
| 237 |
+
gap: 1px;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.market-item {
|
| 241 |
+
display: flex;
|
| 242 |
+
align-items: center;
|
| 243 |
+
gap: 12px;
|
| 244 |
+
padding: 10px 12px;
|
| 245 |
+
border-radius: var(--radius);
|
| 246 |
+
cursor: pointer;
|
| 247 |
+
transition: background 0.2s;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.market-item:hover {
|
| 251 |
+
background: rgba(255, 255, 255, 0.05);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.market-item.active {
|
| 255 |
+
background: var(--bg-active);
|
| 256 |
+
border: 1px solid var(--bdr-accent);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.m-sym {
|
| 260 |
+
flex: 1;
|
| 261 |
+
display: flex;
|
| 262 |
+
flex-direction: column;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.m-ticker {
|
| 266 |
+
font-family: var(--ff-mono);
|
| 267 |
+
font-size: 0.85rem;
|
| 268 |
+
font-weight: 700;
|
| 269 |
+
color: var(--txt-bright);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.m-name {
|
| 273 |
+
font-size: 0.72rem;
|
| 274 |
+
color: var(--txt-secondary);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.m-price-box {
|
| 278 |
+
text-align: right;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.m-price {
|
| 282 |
+
font-family: var(--ff-mono);
|
| 283 |
+
font-size: 0.85rem;
|
| 284 |
+
font-weight: 600;
|
| 285 |
+
color: var(--txt-bright);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.m-change {
|
| 289 |
+
font-size: 0.72rem;
|
| 290 |
+
font-weight: 700;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.m-change.up { color: var(--ok); }
|
| 294 |
+
.m-change.down { color: var(--err); }
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
/* ═══════════════════════════════════════════════
|
| 298 |
+
HEADER
|
| 299 |
+
═══════════════════════════════════════════════ */
|
| 300 |
+
.hdr {
|
| 301 |
+
height: var(--header-h);
|
| 302 |
+
flex-shrink: 0;
|
| 303 |
+
display: flex;
|
| 304 |
+
align-items: center;
|
| 305 |
+
justify-content: space-between;
|
| 306 |
+
gap: 16px;
|
| 307 |
+
padding: 0 20px 0 18px;
|
| 308 |
+
background: var(--bg-glass);
|
| 309 |
+
backdrop-filter: blur(20px) saturate(160%);
|
| 310 |
+
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
| 311 |
+
border-bottom: 1px solid var(--bdr-dim);
|
| 312 |
+
position: relative;
|
| 313 |
+
z-index: 20;
|
| 314 |
+
animation: hdr-slide-in 0.5s ease both;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
/* thin accent line bottom */
|
| 318 |
+
.hdr::after {
|
| 319 |
+
content: '';
|
| 320 |
+
position: absolute;
|
| 321 |
+
bottom: -1px;
|
| 322 |
+
left: 0;
|
| 323 |
+
right: 0;
|
| 324 |
+
height: 1px;
|
| 325 |
+
background: linear-gradient(90deg,
|
| 326 |
+
transparent 0%, var(--accent-mid) 20%,
|
| 327 |
+
var(--accent-mid) 80%, transparent 100%);
|
| 328 |
+
pointer-events: none;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
@keyframes hdr-slide-in {
|
| 332 |
+
from {
|
| 333 |
+
transform: translateY(-100%);
|
| 334 |
+
opacity: 0;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
to {
|
| 338 |
+
transform: translateY(0);
|
| 339 |
+
opacity: 1;
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
/* ── Logo ─────────────────────────────────────── */
|
| 344 |
+
.logo {
|
| 345 |
+
display: flex;
|
| 346 |
+
align-items: center;
|
| 347 |
+
gap: 11px;
|
| 348 |
+
flex-shrink: 0;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.logo-mark {
|
| 352 |
+
position: relative;
|
| 353 |
+
width: 48px;
|
| 354 |
+
height: 48px;
|
| 355 |
+
flex-shrink: 0;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.logo-mark svg {
|
| 359 |
+
width: 100%;
|
| 360 |
+
height: 100%;
|
| 361 |
+
filter: drop-shadow(0 0 6px var(--accent-glow));
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.logo-text {
|
| 365 |
+
display: flex;
|
| 366 |
+
flex-direction: column;
|
| 367 |
+
gap: 1px;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.logo-name {
|
| 371 |
+
font-family: var(--ff-display);
|
| 372 |
+
font-size: 1.45rem;
|
| 373 |
+
font-weight: 700;
|
| 374 |
+
letter-spacing: 0.12em;
|
| 375 |
+
color: #ffffff;
|
| 376 |
+
line-height: 1;
|
| 377 |
+
background: linear-gradient(90deg, #ffffff 0%, #a8f4ff 60%, #ffffff 100%);
|
| 378 |
+
-webkit-background-clip: text;
|
| 379 |
+
-webkit-text-fill-color: transparent;
|
| 380 |
+
background-clip: text;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.logo-name span {
|
| 384 |
+
font-weight: 700;
|
| 385 |
+
letter-spacing: 0.12em;
|
| 386 |
+
opacity: 1;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.logo-sub {
|
| 390 |
+
font-size: 0.78rem;
|
| 391 |
+
font-family: var(--ff-ui);
|
| 392 |
+
font-weight: 400;
|
| 393 |
+
letter-spacing: 0.05em;
|
| 394 |
+
color: #ffffff;
|
| 395 |
+
line-height: 1.2;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
/* ── Divider ──────────────────────────────────── */
|
| 399 |
+
.hdr-divider {
|
| 400 |
+
width: 1px;
|
| 401 |
+
height: 28px;
|
| 402 |
+
background: var(--bdr-base);
|
| 403 |
+
flex-shrink: 0;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
/* ── Controls bar ─────────────────────────────── */
|
| 407 |
+
.ctrls {
|
| 408 |
+
display: flex;
|
| 409 |
+
align-items: center;
|
| 410 |
+
gap: 12px;
|
| 411 |
+
flex: 1;
|
| 412 |
+
justify-content: flex-end;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
/* Search Omnibox */
|
| 416 |
+
.omnibox {
|
| 417 |
+
position: relative;
|
| 418 |
+
width: 320px;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.omnibox input {
|
| 422 |
+
width: 100%;
|
| 423 |
+
height: 38px;
|
| 424 |
+
background: var(--bg-control);
|
| 425 |
+
border: 1px solid var(--bdr-base);
|
| 426 |
+
border-radius: var(--radius);
|
| 427 |
+
padding: 0 12px;
|
| 428 |
+
padding-left: 36px;
|
| 429 |
+
color: var(--txt-bright);
|
| 430 |
+
font-family: var(--ff-ui);
|
| 431 |
+
font-size: 0.9rem;
|
| 432 |
+
outline: none;
|
| 433 |
+
transition: all 0.22s;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.omnibox input:focus {
|
| 437 |
+
border-color: var(--accent);
|
| 438 |
+
box-shadow: 0 0 0 3px var(--accent-lo);
|
| 439 |
+
background: var(--bg-control-hov);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.omnibox i {
|
| 443 |
+
position: absolute;
|
| 444 |
+
left: 12px;
|
| 445 |
+
top: 50%;
|
| 446 |
+
transform: translateY(-50%);
|
| 447 |
+
color: var(--txt-secondary);
|
| 448 |
+
pointer-events: none;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.search-results {
|
| 452 |
+
position: absolute;
|
| 453 |
+
top: 100%;
|
| 454 |
+
left: 0;
|
| 455 |
+
right: 0;
|
| 456 |
+
margin-top: 8px;
|
| 457 |
+
background: var(--bg-glass);
|
| 458 |
+
backdrop-filter: blur(20px);
|
| 459 |
+
border: 1px solid var(--bdr-muted);
|
| 460 |
+
border-radius: var(--radius);
|
| 461 |
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
|
| 462 |
+
max-height: 400px;
|
| 463 |
+
overflow-y: auto;
|
| 464 |
+
z-index: 100;
|
| 465 |
+
display: none;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.search-results.visible {
|
| 469 |
+
display: block;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.search-item {
|
| 473 |
+
padding: 10px 14px;
|
| 474 |
+
display: flex;
|
| 475 |
+
justify-content: space-between;
|
| 476 |
+
align-items: center;
|
| 477 |
+
cursor: pointer;
|
| 478 |
+
transition: background 0.15s;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.search-item:hover {
|
| 482 |
+
background: rgba(255, 255, 255, 0.08);
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.search-item .sym {
|
| 486 |
+
font-family: var(--ff-mono);
|
| 487 |
+
font-weight: 700;
|
| 488 |
+
color: var(--accent);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.search-item .name {
|
| 492 |
+
font-size: 0.8rem;
|
| 493 |
+
color: var(--txt-secondary);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.market-status-bar {
|
| 497 |
+
display: flex;
|
| 498 |
+
align-items: center;
|
| 499 |
+
gap: 16px;
|
| 500 |
+
margin-right: 20px;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
.m-stat {
|
| 504 |
+
display: flex;
|
| 505 |
+
align-items: center;
|
| 506 |
+
gap: 6px;
|
| 507 |
+
font-size: 0.72rem;
|
| 508 |
+
font-weight: 600;
|
| 509 |
+
text-transform: uppercase;
|
| 510 |
+
letter-spacing: 0.05em;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.m-stat .pulse {
|
| 514 |
+
width: 6px;
|
| 515 |
+
height: 6px;
|
| 516 |
+
border-radius: 50%;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.m-stat.open { color: var(--ok); }
|
| 520 |
+
.m-stat.open .pulse { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
|
| 521 |
+
.m-stat.closed { color: var(--txt-muted); }
|
| 522 |
+
.m-stat.closed .pulse { background: var(--txt-muted); }
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
.ctrl-unit {
|
| 526 |
+
display: flex;
|
| 527 |
+
flex-direction: column;
|
| 528 |
+
gap: 3px;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.ctrl-label {
|
| 532 |
+
font-family: var(--ff-display);
|
| 533 |
+
font-size: 0.78rem;
|
| 534 |
+
font-weight: 500;
|
| 535 |
+
letter-spacing: 0.18em;
|
| 536 |
+
color: #ffffff;
|
| 537 |
+
text-transform: uppercase;
|
| 538 |
+
padding-left: 2px;
|
| 539 |
+
line-height: 1;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
/* unified form control look */
|
| 543 |
+
.k-select,
|
| 544 |
+
.k-input {
|
| 545 |
+
height: var(--ctrl-h);
|
| 546 |
+
background: var(--bg-control);
|
| 547 |
+
border: 1px solid var(--bdr-base);
|
| 548 |
+
color: var(--txt-bright);
|
| 549 |
+
padding: 0 10px;
|
| 550 |
+
border-radius: var(--radius);
|
| 551 |
+
font-family: var(--ff-ui);
|
| 552 |
+
font-size: 0.95rem;
|
| 553 |
+
font-weight: 500;
|
| 554 |
+
outline: none;
|
| 555 |
+
cursor: pointer;
|
| 556 |
+
transition:
|
| 557 |
+
border-color 0.22s ease,
|
| 558 |
+
background 0.22s ease,
|
| 559 |
+
box-shadow 0.22s ease;
|
| 560 |
+
appearance: none;
|
| 561 |
+
-webkit-appearance: none;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
.k-select {
|
| 565 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%235080a8' stroke-width='1.4' stroke-linecap='round'/%3E%3C/svg%3E");
|
| 566 |
+
background-repeat: no-repeat;
|
| 567 |
+
background-position: right 9px center;
|
| 568 |
+
padding-right: 28px;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.k-select optgroup {
|
| 572 |
+
background: #060d20;
|
| 573 |
+
color: var(--txt-secondary);
|
| 574 |
+
font-size: 0.75rem;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
.k-select option {
|
| 578 |
+
background: #060d20;
|
| 579 |
+
color: var(--txt-bright);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.k-select:hover,
|
| 583 |
+
.k-input:hover,
|
| 584 |
+
.k-select:focus,
|
| 585 |
+
.k-input:focus {
|
| 586 |
+
border-color: var(--bdr-accent);
|
| 587 |
+
background: var(--bg-control-hov);
|
| 588 |
+
box-shadow: 0 0 0 3px var(--accent-lo), inset 0 0 0 1px var(--accent-lo);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
#symbolSelect {
|
| 592 |
+
width: 280px;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
#timeframeSelect {
|
| 596 |
+
width: 110px;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
#horizonInput {
|
| 600 |
+
width: 90px;
|
| 601 |
+
text-align: center;
|
| 602 |
+
font-family: var(--ff-mono);
|
| 603 |
+
font-size: 0.95rem;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
/* primary button */
|
| 607 |
+
.btn-primary {
|
| 608 |
+
height: var(--ctrl-h);
|
| 609 |
+
padding: 0 18px;
|
| 610 |
+
background: linear-gradient(135deg, rgba(34, 211, 238, 0.18) 0%, rgba(34, 211, 238, 0.10) 100%);
|
| 611 |
+
border: 1px solid var(--bdr-accent);
|
| 612 |
+
color: var(--accent);
|
| 613 |
+
border-radius: var(--radius);
|
| 614 |
+
font-family: var(--ff-display);
|
| 615 |
+
font-size: 0.95rem;
|
| 616 |
+
font-weight: 700;
|
| 617 |
+
letter-spacing: 0.12em;
|
| 618 |
+
text-transform: uppercase;
|
| 619 |
+
cursor: pointer;
|
| 620 |
+
transition:
|
| 621 |
+
background 0.22s ease,
|
| 622 |
+
box-shadow 0.22s ease,
|
| 623 |
+
transform 0.14s ease,
|
| 624 |
+
border-color 0.22s ease;
|
| 625 |
+
white-space: nowrap;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
.btn-primary:hover {
|
| 629 |
+
background: linear-gradient(135deg, rgba(34, 211, 238, 0.30) 0%, rgba(34, 211, 238, 0.18) 100%);
|
| 630 |
+
box-shadow: var(--glow-accent);
|
| 631 |
+
border-color: var(--accent);
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.btn-primary:active {
|
| 635 |
+
transform: scale(0.97);
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
.btn-primary:disabled {
|
| 639 |
+
opacity: 0.35;
|
| 640 |
+
cursor: not-allowed;
|
| 641 |
+
box-shadow: none;
|
| 642 |
+
transform: none;
|
| 643 |
+
border-color: var(--bdr-dim);
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
/* secondary icon button */
|
| 647 |
+
.btn-icon {
|
| 648 |
+
height: var(--ctrl-h);
|
| 649 |
+
width: var(--ctrl-h);
|
| 650 |
+
display: flex;
|
| 651 |
+
align-items: center;
|
| 652 |
+
justify-content: center;
|
| 653 |
+
background: var(--bg-control);
|
| 654 |
+
border: 1px solid var(--bdr-base);
|
| 655 |
+
color: var(--txt-secondary);
|
| 656 |
+
border-radius: var(--radius);
|
| 657 |
+
cursor: pointer;
|
| 658 |
+
font-size: 0.90rem;
|
| 659 |
+
line-height: 1;
|
| 660 |
+
transition:
|
| 661 |
+
border-color 0.22s,
|
| 662 |
+
background 0.22s,
|
| 663 |
+
color 0.22s,
|
| 664 |
+
box-shadow 0.22s,
|
| 665 |
+
transform 0.14s;
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
.btn-icon:hover {
|
| 669 |
+
border-color: var(--bdr-muted);
|
| 670 |
+
background: var(--bg-control-hov);
|
| 671 |
+
color: var(--txt-bright);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.btn-icon:active {
|
| 675 |
+
transform: scale(0.93);
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
/* ═══════════════════════════════════════════════
|
| 679 |
+
MAIN CHART AREA
|
| 680 |
+
═══════════════════════════════════════════════ */
|
| 681 |
+
.main {
|
| 682 |
+
flex: 1;
|
| 683 |
+
position: relative;
|
| 684 |
+
overflow: hidden;
|
| 685 |
+
/* subtle inner top shadow */
|
| 686 |
+
box-shadow: inset 0 8px 24px rgba(0, 0, 0, 0.35);
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
/* ═══════════════════════════════════════════════
|
| 690 |
+
ANALYSIS DASHBOARD v6.0 — 3 GAUGE HERO
|
| 691 |
+
═══════════════════════════════════════════════ */
|
| 692 |
+
.analysis-panel {
|
| 693 |
+
position: absolute;
|
| 694 |
+
inset: 0;
|
| 695 |
+
z-index: 100;
|
| 696 |
+
width: 100%;
|
| 697 |
+
height: 100%;
|
| 698 |
+
padding: 0;
|
| 699 |
+
background: rgba(2, 6, 14, 0.98);
|
| 700 |
+
backdrop-filter: blur(50px) saturate(220%);
|
| 701 |
+
-webkit-backdrop-filter: blur(50px) saturate(220%);
|
| 702 |
+
display: none; /* FULL HIDE */
|
| 703 |
+
flex-direction: column;
|
| 704 |
+
overflow: hidden;
|
| 705 |
+
animation: dash-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 706 |
+
}
|
| 707 |
+
.analysis-panel.active {
|
| 708 |
+
display: flex;
|
| 709 |
+
}
|
| 710 |
+
@keyframes dash-in {
|
| 711 |
+
from { opacity: 0; transform: scale(0.98); }
|
| 712 |
+
to { opacity: 1; transform: scale(1); }
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
/* ── Dashboard Header ── */
|
| 716 |
+
.dash-header {
|
| 717 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 718 |
+
padding: 20px 40px; flex-shrink: 0;
|
| 719 |
+
border-bottom: 1px solid rgba(255,255,255,0.08);
|
| 720 |
+
background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%);
|
| 721 |
+
}
|
| 722 |
+
.dash-header-left { display: flex; align-items: center; gap: 14px; }
|
| 723 |
+
.dash-logo-dot {
|
| 724 |
+
width: 16px; height: 16px; border-radius: 50%;
|
| 725 |
+
background: linear-gradient(135deg, #22d3ee, #3b82f6);
|
| 726 |
+
box-shadow: 0 0 14px rgba(34,211,238,0.6);
|
| 727 |
+
animation: dot-breathe 2.5s ease-in-out infinite;
|
| 728 |
+
}
|
| 729 |
+
@keyframes dot-breathe {
|
| 730 |
+
0%,100% { box-shadow: 0 0 8px rgba(34,211,238,0.4); }
|
| 731 |
+
50% { box-shadow: 0 0 22px rgba(34,211,238,0.8); }
|
| 732 |
+
}
|
| 733 |
+
.dash-title {
|
| 734 |
+
font-family: var(--ff-display); font-size: 1.6rem; font-weight: 800;
|
| 735 |
+
color: #ffffff; letter-spacing: 0.12em; text-transform: uppercase;
|
| 736 |
+
}
|
| 737 |
+
.dash-subtitle {
|
| 738 |
+
font-size: 1rem; color: rgba(255,255,255,0.5); margin-left: 15px;
|
| 739 |
+
letter-spacing: 0.08em;
|
| 740 |
+
}
|
| 741 |
+
.dash-close {
|
| 742 |
+
width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
|
| 743 |
+
cursor: pointer; color: rgba(255,255,255,0.4); border-radius: 10px;
|
| 744 |
+
border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03);
|
| 745 |
+
transition: all 0.2s;
|
| 746 |
+
}
|
| 747 |
+
.dash-close:hover { background: rgba(255,255,255,0.1); color: #fff; border-color: rgba(255,255,255,0.2); }
|
| 748 |
+
|
| 749 |
+
/* ── Dashboard Body — top gauges + bottom tables ── */
|
| 750 |
+
.dash-body {
|
| 751 |
+
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
/* ── TOP: 3 Big Gauges Row ── */
|
| 755 |
+
.dash-gauges-hero {
|
| 756 |
+
display: flex; justify-content: center; align-items: stretch;
|
| 757 |
+
gap: 0; flex-shrink: 0;
|
| 758 |
+
border-bottom: 1px solid rgba(255,255,255,0.06);
|
| 759 |
+
}
|
| 760 |
+
.gauge-hero-card {
|
| 761 |
+
flex: 1; min-width: 0;
|
| 762 |
+
display: flex; flex-direction: column; align-items: center;
|
| 763 |
+
justify-content: center; gap: 15px;
|
| 764 |
+
padding: 40px 30px;
|
| 765 |
+
border-right: 1px solid rgba(255,255,255,0.08);
|
| 766 |
+
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
| 767 |
+
position: relative;
|
| 768 |
+
}
|
| 769 |
+
.gauge-hero-card:last-child { border-right: none; }
|
| 770 |
+
.gauge-hero-card:hover { background: rgba(255,255,255,0.03); transform: translateY(-4px); }
|
| 771 |
+
.gauge-hero-card.hero-total {
|
| 772 |
+
background: linear-gradient(180deg, rgba(34, 211, 238, 0.08) 0%, rgba(2,6,14,0) 100%);
|
| 773 |
+
}
|
| 774 |
+
.gauge-hero-card.hero-total::before {
|
| 775 |
+
content: ''; position: absolute; top: 0; left: 10%; right: 10%; height: 4px;
|
| 776 |
+
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
| 777 |
+
border-radius: 4px;
|
| 778 |
+
box-shadow: 0 0 20px var(--accent-glow);
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
.gauge-hero-title {
|
| 782 |
+
font-family: var(--ff-display); font-size: 1.3rem; font-weight: 800;
|
| 783 |
+
color: rgba(255,255,255,0.9); text-transform: uppercase;
|
| 784 |
+
letter-spacing: 0.22em; text-align: center;
|
| 785 |
+
}
|
| 786 |
+
.gauge-hero-title.title-total {
|
| 787 |
+
font-size: 1.55rem; color: #ffffff;
|
| 788 |
+
text-shadow: 0 0 35px rgba(34,211,238,0.5);
|
| 789 |
+
}
|
| 790 |
+
.gauge-hero-svg-wrap {
|
| 791 |
+
position: relative;
|
| 792 |
+
width: 320px; height: 220px;
|
| 793 |
+
display: flex; align-items: center; justify-content: center;
|
| 794 |
+
}
|
| 795 |
+
.gauge-hero-value-box {
|
| 796 |
+
position: absolute;
|
| 797 |
+
top: 62%; left: 50%;
|
| 798 |
+
transform: translate(-50%, -50%);
|
| 799 |
+
display: flex; flex-direction: column; align-items: center;
|
| 800 |
+
}
|
| 801 |
+
.gauge-hero-value-num {
|
| 802 |
+
font-family: var(--ff-display); font-size: 2.2rem; font-weight: 900;
|
| 803 |
+
color: #fff; line-height: 1;
|
| 804 |
+
text-shadow: 0 0 20px rgba(255,255,255,0.3);
|
| 805 |
+
}
|
| 806 |
+
.gauge-hero-value-label {
|
| 807 |
+
font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4);
|
| 808 |
+
text-transform: uppercase; letter-spacing: 0.1em; margin-top: 4px;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.gauge-hero-signal {
|
| 812 |
+
font-family: var(--ff-display); font-size: 1.8rem; font-weight: 900;
|
| 813 |
+
text-transform: uppercase; letter-spacing: 0.15em;
|
| 814 |
+
text-shadow: 0 0 20px rgba(0,0,0,0.5);
|
| 815 |
+
margin-top: 5px;
|
| 816 |
+
}
|
| 817 |
+
.gauge-hero-signal.strong-buy { color: #22c55e; text-shadow: 0 0 15px rgba(34, 197, 94, 0.4); }
|
| 818 |
+
.gauge-hero-signal.buy { color: #86efac; }
|
| 819 |
+
.gauge-hero-signal.strong-sell { color: #ef4444; text-shadow: 0 0 15px rgba(239, 68, 68, 0.4); }
|
| 820 |
+
.gauge-hero-signal.sell { color: #fca5a5; }
|
| 821 |
+
.gauge-hero-signal.neutral { color: #cbd5e1; }
|
| 822 |
+
.gauge-hero-signal.signal-total { font-size: 2.2rem; }
|
| 823 |
+
.gauge-hero-counts {
|
| 824 |
+
display: flex; gap: 20px; font-size: 0.8rem; color: rgba(255,255,255,0.4);
|
| 825 |
+
letter-spacing: 0.02em;
|
| 826 |
+
}
|
| 827 |
+
.gauge-hero-counts .ghc-label { text-transform: uppercase; font-weight: 600; }
|
| 828 |
+
.gauge-hero-counts .ghc-value {
|
| 829 |
+
font-family: var(--ff-mono); font-weight: 900;
|
| 830 |
+
font-size: 1.1rem; color: #ffffff; display: block;
|
| 831 |
+
margin-top: 2px;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
/* ── BOTTOM: Data Tables Grid ── */
|
| 835 |
+
.dash-tables-row {
|
| 836 |
+
flex: 1; display: grid; overflow: hidden;
|
| 837 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 838 |
+
gap: 0;
|
| 839 |
+
}
|
| 840 |
+
.dash-col {
|
| 841 |
+
display: flex; flex-direction: column;
|
| 842 |
+
border-right: 1px solid rgba(255,255,255,0.04);
|
| 843 |
+
overflow: hidden;
|
| 844 |
+
}
|
| 845 |
+
.dash-col:last-child { border-right: none; }
|
| 846 |
+
|
| 847 |
+
.dc-header {
|
| 848 |
+
padding: 14px 20px; flex-shrink: 0;
|
| 849 |
+
border-bottom: 1px solid rgba(255,255,255,0.06);
|
| 850 |
+
background: rgba(255,255,255,0.018);
|
| 851 |
+
font-family: var(--ff-display);
|
| 852 |
+
font-size: 0.85rem; font-weight: 800; color: #ffffff;
|
| 853 |
+
text-transform: uppercase; letter-spacing: 0.12em;
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
.dash-table-wrap {
|
| 857 |
+
flex: 1; overflow-y: auto; overflow-x: hidden;
|
| 858 |
+
}
|
| 859 |
+
.dash-table-wrap::-webkit-scrollbar { width: 3px; }
|
| 860 |
+
.dash-table-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 10px; }
|
| 861 |
+
|
| 862 |
+
.dt { width: 100%; border-collapse: collapse; }
|
| 863 |
+
.dt td {
|
| 864 |
+
padding: 9px 18px; font-size: 0.88rem;
|
| 865 |
+
border-bottom: 1px solid rgba(255,255,255,0.03);
|
| 866 |
+
}
|
| 867 |
+
.dt td:first-child { color: rgba(255,255,255,0.8); font-weight: 500; }
|
| 868 |
+
.dt .dt-val {
|
| 869 |
+
font-family: var(--ff-mono); font-weight: 700; color: #ffffff; text-align: right;
|
| 870 |
+
}
|
| 871 |
+
.dt .dt-act {
|
| 872 |
+
font-weight: 800; text-align: right; font-size: 0.85rem; letter-spacing: 0.03em;
|
| 873 |
+
}
|
| 874 |
+
.dt-act-buy { color: #60a5fa !important; }
|
| 875 |
+
.dt-act-sell { color: #fb7185 !important; }
|
| 876 |
+
.dt-act-neut { color: rgba(255,255,255,0.3) !important; }
|
| 877 |
+
|
| 878 |
+
.pivot-table { width: 100%; border-collapse: collapse; }
|
| 879 |
+
.pivot-table th {
|
| 880 |
+
padding: 8px 10px; font-size: 0.7rem; font-weight: 800;
|
| 881 |
+
color: rgba(255,255,255,0.4); text-transform: uppercase;
|
| 882 |
+
letter-spacing: 0.08em; text-align: center;
|
| 883 |
+
border-bottom: 1px solid rgba(255,255,255,0.06);
|
| 884 |
+
}
|
| 885 |
+
.pivot-table td {
|
| 886 |
+
padding: 7px 10px; font-family: var(--ff-mono); font-size: 0.85rem;
|
| 887 |
+
font-weight: 600; color: #ffffff; text-align: center;
|
| 888 |
+
border-bottom: 1px solid rgba(255,255,255,0.025);
|
| 889 |
+
}
|
| 890 |
+
.pivot-table td:first-child {
|
| 891 |
+
font-weight: 800; color: rgba(255,255,255,0.55); font-family: var(--ff-ui);
|
| 892 |
+
text-align: left; padding-left: 18px;
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
.summary-disclaimer {
|
| 896 |
+
padding: 10px 20px;
|
| 897 |
+
font-size: 0.72rem; color: rgba(255,255,255,0.25); line-height: 1.5;
|
| 898 |
+
border-top: 1px solid rgba(255,255,255,0.04);
|
| 899 |
+
text-align: center; flex-shrink: 0;
|
| 900 |
+
}
|
| 901 |
+
.summary-disclaimer strong { color: rgba(255,255,255,0.4); }
|
| 902 |
+
|
| 903 |
+
/* ── Loading state ── */
|
| 904 |
+
.dash-loading {
|
| 905 |
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
| 906 |
+
height: 100%; gap: 16px; color: rgba(255,255,255,0.4);
|
| 907 |
+
}
|
| 908 |
+
.dash-loading .loader-ring { border-top-color: var(--accent); }
|
| 909 |
+
.dash-loading p { font-size: 0.9rem; letter-spacing: 0.05em; animation: loader-pulse 1.4s ease-in-out infinite; }
|
| 910 |
+
|
| 911 |
+
/* Dashboard responsive */
|
| 912 |
+
@media (max-width: 1200px) {
|
| 913 |
+
.dash-tables-row { grid-template-columns: 1fr 1fr; }
|
| 914 |
+
.dash-col.col-pivots { display: none; }
|
| 915 |
+
.gauge-hero-card { padding: 20px 14px 18px; }
|
| 916 |
+
.gauge-hero-svg-wrap { width: 180px; height: 108px; }
|
| 917 |
+
}
|
| 918 |
+
@media (max-width: 800px) {
|
| 919 |
+
.dash-gauges-hero { flex-direction: column; }
|
| 920 |
+
.gauge-hero-card { border-right: none; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
| 921 |
+
.dash-tables-row { grid-template-columns: 1fr; }
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
#chart {
|
| 925 |
+
position: absolute;
|
| 926 |
+
inset: 0;
|
| 927 |
+
width: 100%;
|
| 928 |
+
height: 100%;
|
| 929 |
+
z-index: 5;
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
/* Futuristic AI Overlay */
|
| 933 |
+
.chart-bg-overlay {
|
| 934 |
+
position: absolute;
|
| 935 |
+
inset: 0;
|
| 936 |
+
pointer-events: none;
|
| 937 |
+
z-index: 1;
|
| 938 |
+
opacity: 0.17;
|
| 939 |
+
background-image: url('ai_analysis_bg.png');
|
| 940 |
+
background-size: contain;
|
| 941 |
+
background-repeat: no-repeat;
|
| 942 |
+
background-position: left bottom;
|
| 943 |
+
mask-image: linear-gradient(to right, black 20%, transparent 80%);
|
| 944 |
+
-webkit-mask-image: linear-gradient(to right, black 20%, transparent 80%);
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
.chart-logo-overlay {
|
| 948 |
+
position: absolute;
|
| 949 |
+
bottom: 35px;
|
| 950 |
+
right: 85px;
|
| 951 |
+
pointer-events: none;
|
| 952 |
+
z-index: 2;
|
| 953 |
+
font-family: var(--ff-display);
|
| 954 |
+
font-size: 2.2rem;
|
| 955 |
+
font-weight: 700;
|
| 956 |
+
letter-spacing: 0.25em;
|
| 957 |
+
line-height: 1;
|
| 958 |
+
background: linear-gradient(180deg, #fff 0%, var(--accent) 50%, #0a4d5c 100%);
|
| 959 |
+
-webkit-background-clip: text;
|
| 960 |
+
-webkit-text-fill-color: transparent;
|
| 961 |
+
background-clip: text;
|
| 962 |
+
filter: drop-shadow(0 0 15px rgba(34, 211, 238, 0.45));
|
| 963 |
+
opacity: 0.55;
|
| 964 |
+
text-transform: uppercase;
|
| 965 |
+
white-space: nowrap;
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
/* ── Terminal Loader Overlay (Strict Isolation) ── */
|
| 969 |
+
.terminal-loader-overlay {
|
| 970 |
+
position: absolute;
|
| 971 |
+
inset: 0 !important;
|
| 972 |
+
width: 100% !important;
|
| 973 |
+
height: 100% !important;
|
| 974 |
+
display: none; /* FULL HIDE */
|
| 975 |
+
flex-direction: column;
|
| 976 |
+
align-items: center;
|
| 977 |
+
justify-content: center;
|
| 978 |
+
background: rgba(1, 4, 12, 0.98);
|
| 979 |
+
backdrop-filter: blur(50px) saturate(200%);
|
| 980 |
+
-webkit-backdrop-filter: blur(50px) saturate(200%);
|
| 981 |
+
z-index: 9999;
|
| 982 |
+
opacity: 0;
|
| 983 |
+
transition: opacity 0.5s ease;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
.terminal-loader-overlay.active {
|
| 987 |
+
display: flex;
|
| 988 |
+
opacity: 1;
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
.loader-inner {
|
| 992 |
+
display: flex;
|
| 993 |
+
flex-direction: column;
|
| 994 |
+
align-items: center;
|
| 995 |
+
gap: 14px;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
.loader-ring {
|
| 999 |
+
width: 42px;
|
| 1000 |
+
height: 42px;
|
| 1001 |
+
border-radius: 50%;
|
| 1002 |
+
border: 1.5px solid var(--bdr-dim);
|
| 1003 |
+
border-top-color: var(--accent);
|
| 1004 |
+
animation: spin 0.8s linear infinite;
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
@keyframes spin {
|
| 1008 |
+
to {
|
| 1009 |
+
transform: rotate(360deg);
|
| 1010 |
+
}
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.loader-text {
|
| 1014 |
+
font-family: var(--ff-display);
|
| 1015 |
+
font-size: 0.68rem;
|
| 1016 |
+
letter-spacing: 0.22em;
|
| 1017 |
+
color: var(--txt-secondary);
|
| 1018 |
+
text-transform: uppercase;
|
| 1019 |
+
animation: loader-pulse 1.4s ease-in-out infinite;
|
| 1020 |
+
}
|
| 1021 |
+
|
| 1022 |
+
@keyframes loader-pulse {
|
| 1023 |
+
|
| 1024 |
+
0%,
|
| 1025 |
+
100% {
|
| 1026 |
+
opacity: 0.4;
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
50% {
|
| 1030 |
+
opacity: 1;
|
| 1031 |
+
}
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
/* ═══════════════════════════════════════════════
|
| 1035 |
+
STATUS OVERLAY
|
| 1036 |
+
═══════════════════════════════════════════════ */
|
| 1037 |
+
.status-wrap {
|
| 1038 |
+
position: absolute;
|
| 1039 |
+
top: 16px;
|
| 1040 |
+
left: 16px;
|
| 1041 |
+
z-index: 10;
|
| 1042 |
+
pointer-events: none;
|
| 1043 |
+
animation: fade-in 0.6s 0.3s ease both;
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
@keyframes fade-in {
|
| 1047 |
+
from {
|
| 1048 |
+
opacity: 0;
|
| 1049 |
+
transform: translateY(-6px);
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
to {
|
| 1053 |
+
opacity: 1;
|
| 1054 |
+
transform: translateY(0);
|
| 1055 |
+
}
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
.status-pill {
|
| 1059 |
+
display: inline-flex;
|
| 1060 |
+
align-items: center;
|
| 1061 |
+
gap: 9px;
|
| 1062 |
+
padding: 7px 14px 7px 10px;
|
| 1063 |
+
background: var(--bg-glass);
|
| 1064 |
+
backdrop-filter: blur(14px) saturate(160%);
|
| 1065 |
+
-webkit-backdrop-filter: blur(14px) saturate(160%);
|
| 1066 |
+
border: 1px solid var(--bdr-dim);
|
| 1067 |
+
border-radius: 20px;
|
| 1068 |
+
transition: border-color 0.3s ease;
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
.status-pill.ok {
|
| 1072 |
+
border-color: rgba(45, 212, 170, 0.30);
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
.status-pill.error {
|
| 1076 |
+
border-color: rgba(240, 96, 112, 0.30);
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
.status-pill.loading {
|
| 1080 |
+
border-color: rgba(34, 211, 238, 0.28);
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
.dot {
|
| 1084 |
+
width: 7px;
|
| 1085 |
+
height: 7px;
|
| 1086 |
+
border-radius: 50%;
|
| 1087 |
+
background: var(--txt-muted);
|
| 1088 |
+
flex-shrink: 0;
|
| 1089 |
+
transition: background 0.3s, box-shadow 0.3s;
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
.dot.ok {
|
| 1093 |
+
background: var(--ok);
|
| 1094 |
+
box-shadow: 0 0 8px var(--ok-glow);
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
.dot.error {
|
| 1098 |
+
background: var(--err);
|
| 1099 |
+
box-shadow: 0 0 8px var(--err-glow);
|
| 1100 |
+
}
|
| 1101 |
+
|
| 1102 |
+
.dot.loading {
|
| 1103 |
+
background: var(--accent);
|
| 1104 |
+
box-shadow: 0 0 8px var(--accent-glow);
|
| 1105 |
+
animation: dot-pulse 1.2s ease-in-out infinite;
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
@keyframes dot-pulse {
|
| 1109 |
+
|
| 1110 |
+
0%,
|
| 1111 |
+
100% {
|
| 1112 |
+
transform: scale(0.85);
|
| 1113 |
+
opacity: 0.5;
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
50% {
|
| 1117 |
+
transform: scale(1.20);
|
| 1118 |
+
opacity: 1.0;
|
| 1119 |
+
}
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
.status-label {
|
| 1123 |
+
font-family: var(--ff-ui);
|
| 1124 |
+
font-size: 0.72rem;
|
| 1125 |
+
font-weight: 500;
|
| 1126 |
+
color: var(--txt-primary);
|
| 1127 |
+
letter-spacing: 0.02em;
|
| 1128 |
+
white-space: nowrap;
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
/* ═══════════════════════════════════════════════
|
| 1132 |
+
SCROLLBAR
|
| 1133 |
+
═══════════════════════════════════════════════ */
|
| 1134 |
+
::-webkit-scrollbar {
|
| 1135 |
+
width: 4px;
|
| 1136 |
+
height: 4px;
|
| 1137 |
+
}
|
| 1138 |
+
|
| 1139 |
+
::-webkit-scrollbar-track {
|
| 1140 |
+
background: transparent;
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
::-webkit-scrollbar-thumb {
|
| 1144 |
+
background: var(--bdr-base);
|
| 1145 |
+
border-radius: 2px;
|
| 1146 |
+
}
|
| 1147 |
+
|
| 1148 |
+
::-webkit-scrollbar-thumb:hover {
|
| 1149 |
+
background: var(--txt-muted);
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
/* ═══════════════════════════════════════════════
|
| 1153 |
+
RESPONSIVE
|
| 1154 |
+
═══════════════════════════════════════════════ */
|
| 1155 |
+
@media (max-width: 960px) {
|
| 1156 |
+
.ctrl-label {
|
| 1157 |
+
display: none;
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
.ctrl-unit {
|
| 1161 |
+
flex-direction: row;
|
| 1162 |
+
align-items: center;
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
#symbolSelect {
|
| 1166 |
+
width: 150px;
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
.logo-sub {
|
| 1170 |
+
display: none;
|
| 1171 |
+
}
|
| 1172 |
+
}
|
| 1173 |
+
|
| 1174 |
+
@media (max-width: 680px) {
|
| 1175 |
+
.hdr {
|
| 1176 |
+
padding: 0 12px;
|
| 1177 |
+
gap: 8px;
|
| 1178 |
+
}
|
| 1179 |
+
|
| 1180 |
+
.logo-sub {
|
| 1181 |
+
display: none;
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
.hdr-divider {
|
| 1185 |
+
display: none;
|
| 1186 |
+
}
|
| 1187 |
+
|
| 1188 |
+
#symbolSelect {
|
| 1189 |
+
width: 130px;
|
| 1190 |
+
}
|
| 1191 |
+
|
| 1192 |
+
#timeframeSelect {
|
| 1193 |
+
width: 60px;
|
| 1194 |
+
}
|
| 1195 |
+
|
| 1196 |
+
#horizonInput {
|
| 1197 |
+
width: 56px;
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
.btn-primary {
|
| 1201 |
+
padding: 0 12px;
|
| 1202 |
+
font-size: 0.68rem;
|
| 1203 |
+
letter-spacing: 0.06em;
|
| 1204 |
+
}
|
| 1205 |
+
}
|
| 1206 |
+
</style>
|
| 1207 |
+
</head>
|
| 1208 |
+
|
| 1209 |
+
<body>
|
| 1210 |
+
<div id="app">
|
| 1211 |
+
|
| 1212 |
+
<!-- ── HEADER ───────────────────────────────── -->
|
| 1213 |
+
<header class="hdr">
|
| 1214 |
+
|
| 1215 |
+
<!-- Logo -->
|
| 1216 |
+
<div class="logo">
|
| 1217 |
+
<div class="logo-mark">
|
| 1218 |
+
<!-- Stylised K hexagon mark -->
|
| 1219 |
+
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 1220 |
+
<polygon points="16,2 28,9 28,23 16,30 4,23 4,9" stroke="rgba(34,211,238,0.55)" stroke-width="1"
|
| 1221 |
+
fill="rgba(34,211,238,0.06)" />
|
| 1222 |
+
<polygon points="16,6 24,11 24,21 16,26 8,21 8,11" stroke="rgba(34,211,238,0.22)" stroke-width="0.5"
|
| 1223 |
+
fill="none" />
|
| 1224 |
+
<!-- AI shape -->
|
| 1225 |
+
<path d="M9,22 L13,10 L17,22 M10.5,18 L15.5,18" stroke="#22d3ee" stroke-width="1.8" stroke-linecap="round"
|
| 1226 |
+
stroke-linejoin="round" />
|
| 1227 |
+
<line x1="22" y1="10" x2="22" y2="22" stroke="#22d3ee" stroke-width="1.8" stroke-linecap="round" />
|
| 1228 |
+
<!-- corner accents -->
|
| 1229 |
+
<circle cx="16" cy="2" r="1" fill="rgba(34,211,238,0.5)" />
|
| 1230 |
+
<circle cx="28" cy="9" r="1" fill="rgba(34,211,238,0.3)" />
|
| 1231 |
+
<circle cx="4" cy="23" r="1" fill="rgba(34,211,238,0.3)" />
|
| 1232 |
+
</svg>
|
| 1233 |
+
</div>
|
| 1234 |
+
<!-- Market Status -->
|
| 1235 |
+
<div class="market-status-bar" id="marketStatusBar"></div>
|
| 1236 |
+
</div>
|
| 1237 |
+
|
| 1238 |
+
<div class="hdr-divider"></div>
|
| 1239 |
+
|
| 1240 |
+
<!-- Controls -->
|
| 1241 |
+
<div class="ctrls">
|
| 1242 |
+
|
| 1243 |
+
<button class="btn-icon" id="toggleMarketBtn" title="Ẩn/Hiện Thị trường">
|
| 1244 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 1245 |
+
<line x1="3" y1="12" x2="21" y2="12"></line>
|
| 1246 |
+
<line x1="3" y1="6" x2="21" y2="6"></line>
|
| 1247 |
+
<line x1="3" y1="18" x2="21" y2="18"></line>
|
| 1248 |
+
</svg>
|
| 1249 |
+
</button>
|
| 1250 |
+
|
| 1251 |
+
<!-- Omnibox Search -->
|
| 1252 |
+
<div class="omnibox">
|
| 1253 |
+
<i><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></i>
|
| 1254 |
+
<input type="text" id="symbolSearch" placeholder="Tìm kiếm mã (VD: Gold, BTC, AAPL...)" autocomplete="off">
|
| 1255 |
+
<div class="search-results" id="searchResults"></div>
|
| 1256 |
+
</div>
|
| 1257 |
+
|
| 1258 |
+
<div class="ctrl-unit">
|
| 1259 |
+
<span class="ctrl-label">Khung thời gian</span>
|
| 1260 |
+
<select class="k-select" id="timeframeSelect">
|
| 1261 |
+
<option>1m</option>
|
| 1262 |
+
<option>5m</option>
|
| 1263 |
+
<option>15m</option>
|
| 1264 |
+
<option>1h</option>
|
| 1265 |
+
<option>4h</option>
|
| 1266 |
+
<option selected>1d</option>
|
| 1267 |
+
<option>1w</option>
|
| 1268 |
+
</select>
|
| 1269 |
+
</div>
|
| 1270 |
+
|
| 1271 |
+
<div class="ctrl-unit">
|
| 1272 |
+
<span class="ctrl-label">Dự báo (nến)</span>
|
| 1273 |
+
<input class="k-input" id="horizonInput" type="number" min="5" max="300" value="10" />
|
| 1274 |
+
</div>
|
| 1275 |
+
|
| 1276 |
+
<div class="ctrl-unit">
|
| 1277 |
+
<span class="ctrl-label">Chỉ báo</span>
|
| 1278 |
+
<select class="k-select" id="indicatorSelect" style="width: 140px;">
|
| 1279 |
+
<option value="none">Không có</option>
|
| 1280 |
+
<option value="bb">Bollinger Bands</option>
|
| 1281 |
+
<option value="rsi">RSI (14)</option>
|
| 1282 |
+
<option value="both">Cả hai</option>
|
| 1283 |
+
</select>
|
| 1284 |
+
</div>
|
| 1285 |
+
|
| 1286 |
+
<button class="btn-primary" id="refreshBtn">Phân tích</button>
|
| 1287 |
+
|
| 1288 |
+
|
| 1289 |
+
<button class="btn-icon" id="fitBtn" title="Khớp toàn bộ dữ liệu">
|
| 1290 |
+
<!-- expand icon -->
|
| 1291 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 1292 |
+
<path d="M1 5V1h4M9 1h4v4M13 9v4H9M5 13H1V9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"
|
| 1293 |
+
stroke-linejoin="round" />
|
| 1294 |
+
</svg>
|
| 1295 |
+
</button>
|
| 1296 |
+
|
| 1297 |
+
</div>
|
| 1298 |
+
</header>
|
| 1299 |
+
|
| 1300 |
+
<!-- ── BODY ──────────────────────────────── -->
|
| 1301 |
+
<div class="app-body">
|
| 1302 |
+
<div class="sidebar-trigger" id="reopenSidebarBtn" title="Mở danh sách thị trường">
|
| 1303 |
+
<span>Thị trường</span>
|
| 1304 |
+
</div>
|
| 1305 |
+
|
| 1306 |
+
<!-- Side Sidebar (Market Overview) -->
|
| 1307 |
+
<aside class="sidebar" id="sidebar">
|
| 1308 |
+
<div class="sidebar-head">
|
| 1309 |
+
<span class="sidebar-title">Thị trường</span>
|
| 1310 |
+
<button class="btn-icon" id="toggleSidebarBtn" style="width: 28px; height: 28px; border: none; background: transparent;">
|
| 1311 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 1312 |
+
<polyline points="15 18 9 12 15 6"></polyline>
|
| 1313 |
+
</svg>
|
| 1314 |
+
</button>
|
| 1315 |
+
</div>
|
| 1316 |
+
<div class="sidebar-content">
|
| 1317 |
+
<div class="market-list" id="marketOverview">
|
| 1318 |
+
<!-- Market items will be injected here -->
|
| 1319 |
+
</div>
|
| 1320 |
+
</div>
|
| 1321 |
+
</aside>
|
| 1322 |
+
|
| 1323 |
+
<!-- Main Area -->
|
| 1324 |
+
<main class="main">
|
| 1325 |
+
|
| 1326 |
+
<!-- Status badge -->
|
| 1327 |
+
<div class="status-wrap">
|
| 1328 |
+
<div class="status-pill" id="statusPill">
|
| 1329 |
+
<div class="dot" id="statusDot"></div>
|
| 1330 |
+
<span class="status-label" id="statusText">Hệ thống sẵn sàng</span>
|
| 1331 |
+
</div>
|
| 1332 |
+
</div>
|
| 1333 |
+
|
| 1334 |
+
<!-- Terminal Loading overlay -->
|
| 1335 |
+
<div class="terminal-loader-overlay" id="terminalLoader">
|
| 1336 |
+
<div class="loader-inner">
|
| 1337 |
+
<div class="loader-ring"></div>
|
| 1338 |
+
<div class="loader-text" id="loaderText">Hệ thống đang nạp dữ liệu...</div>
|
| 1339 |
+
</div>
|
| 1340 |
+
</div>
|
| 1341 |
+
|
| 1342 |
+
<!-- Futuristic Overlays -->
|
| 1343 |
+
<div class="chart-bg-overlay"></div>
|
| 1344 |
+
<div class="chart-logo-overlay">KRONOS AI</div>
|
| 1345 |
+
|
| 1346 |
+
<!-- Chart canvas -->
|
| 1347 |
+
<div id="chart"></div>
|
| 1348 |
+
|
| 1349 |
+
<aside class="analysis-panel hidden" id="analysisPanel"></aside>
|
| 1350 |
+
|
| 1351 |
+
</main>
|
| 1352 |
+
</div>
|
| 1353 |
+
</div>
|
| 1354 |
+
|
| 1355 |
+
<!-- ══════════════════════════════════════════════
|
| 1356 |
+
JAVASCRIPT — all original logic preserved
|
| 1357 |
+
══════════════════════════════════════════════ -->
|
| 1358 |
+
<script>
|
| 1359 |
+
const API_BASE = (window.location.origin && window.location.origin !== 'null')
|
| 1360 |
+
? window.location.origin
|
| 1361 |
+
: 'http://127.0.0.1:8000';
|
| 1362 |
+
|
| 1363 |
+
/* ── DOM refs ──────────────────────────────── */
|
| 1364 |
+
/* ── DOM refs ──────────────────────────────── */
|
| 1365 |
+
const symbolSearch = document.getElementById('symbolSearch');
|
| 1366 |
+
const searchResults = document.getElementById('searchResults');
|
| 1367 |
+
const timeframeSelect = document.getElementById('timeframeSelect');
|
| 1368 |
+
const horizonInput = document.getElementById('horizonInput');
|
| 1369 |
+
const refreshBtn = document.getElementById('refreshBtn');
|
| 1370 |
+
const fitBtn = document.getElementById('fitBtn');
|
| 1371 |
+
const statusText = document.getElementById('statusText');
|
| 1372 |
+
const statusDot = document.getElementById('statusDot');
|
| 1373 |
+
const statusPill = document.getElementById('statusPill');
|
| 1374 |
+
const terminalLoader = document.getElementById('terminalLoader');
|
| 1375 |
+
const loaderText = document.getElementById('loaderText');
|
| 1376 |
+
const analysisPanel = document.getElementById('analysisPanel');
|
| 1377 |
+
const sidebar = document.getElementById('sidebar');
|
| 1378 |
+
const toggleSidebarBtn = document.getElementById('toggleSidebarBtn');
|
| 1379 |
+
const marketOverview = document.getElementById('marketOverview');
|
| 1380 |
+
const marketStatusBar = document.getElementById('marketStatusBar');
|
| 1381 |
+
const indicatorSelect = document.getElementById('indicatorSelect');
|
| 1382 |
+
|
| 1383 |
+
/* ── State ─────────────────────────────────── */
|
| 1384 |
+
let currentSymbol = 'XAUUSD';
|
| 1385 |
+
let currentInterval = '1d';
|
| 1386 |
+
let ws = null;
|
| 1387 |
+
let fetchController = null;
|
| 1388 |
+
const symbolMap = new Map();
|
| 1389 |
+
const timeframeMap = {
|
| 1390 |
+
'1m': '1 Phút', '5m': '5 Phút', '15m': '15 Phút',
|
| 1391 |
+
'1h': '1 Giờ', '4h': '4 Giờ', '1d': '1 Ngày', '1w': '1 Tuần'
|
| 1392 |
+
};
|
| 1393 |
+
|
| 1394 |
+
/* ── Indicator Calculation Helpers ────────── */
|
| 1395 |
+
/* ── WebSocket Management ────────────────────── */
|
| 1396 |
+
function connectWS(symbol) {
|
| 1397 |
+
if (ws) {
|
| 1398 |
+
ws.close();
|
| 1399 |
+
ws = null;
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 1403 |
+
const wsUrl = `${API_BASE.replace(/^https?:\/\//, wsProtocol)}/ws/price/${symbol}`;
|
| 1404 |
+
|
| 1405 |
+
console.log(`[WS] Connecting to ${wsUrl}`);
|
| 1406 |
+
ws = new WebSocket(wsUrl);
|
| 1407 |
+
|
| 1408 |
+
ws.onmessage = (event) => {
|
| 1409 |
+
const data = JSON.parse(event.data);
|
| 1410 |
+
if (data.error) return;
|
| 1411 |
+
|
| 1412 |
+
// Update current candle if price changed
|
| 1413 |
+
const lastCandle = lastCandleData;
|
| 1414 |
+
if (lastCandle && data.price) {
|
| 1415 |
+
const update = {
|
| 1416 |
+
time: lastCandle.time,
|
| 1417 |
+
open: lastCandle.open,
|
| 1418 |
+
high: Math.max(lastCandle.high, data.price),
|
| 1419 |
+
low: Math.min(lastCandle.low, data.price),
|
| 1420 |
+
close: data.price
|
| 1421 |
+
};
|
| 1422 |
+
candleSeries.update(update);
|
| 1423 |
+
lastCandleData = update;
|
| 1424 |
+
}
|
| 1425 |
+
|
| 1426 |
+
// Update overview items if they exist
|
| 1427 |
+
updateOverviewItem(data);
|
| 1428 |
+
};
|
| 1429 |
+
|
| 1430 |
+
ws.onclose = () => {
|
| 1431 |
+
console.log('[WS] Disconnected');
|
| 1432 |
+
// Reconnect after 5s if still active
|
| 1433 |
+
setTimeout(() => {
|
| 1434 |
+
if (currentSymbol === symbol) connectWS(symbol);
|
| 1435 |
+
}, 5000);
|
| 1436 |
+
};
|
| 1437 |
+
}
|
| 1438 |
+
|
| 1439 |
+
let lastCandleData = null;
|
| 1440 |
+
|
| 1441 |
+
/* ── Market Status & Overview ────────────────── */
|
| 1442 |
+
async function refreshMarketStatus() {
|
| 1443 |
+
try {
|
| 1444 |
+
const data = await apiRequest('/api/market-status');
|
| 1445 |
+
marketStatusBar.innerHTML = data.markets.map(m => `
|
| 1446 |
+
<div class="m-stat ${m.status}">
|
| 1447 |
+
<div class="pulse"></div>
|
| 1448 |
+
<span>${m.market}</span>
|
| 1449 |
+
</div>
|
| 1450 |
+
`).join('');
|
| 1451 |
+
} catch (e) {
|
| 1452 |
+
console.warn('Market status fetch failed', e);
|
| 1453 |
+
}
|
| 1454 |
+
}
|
| 1455 |
+
|
| 1456 |
+
async function refreshOverview() {
|
| 1457 |
+
try {
|
| 1458 |
+
const symbol = currentSymbol || 'BTCUSD';
|
| 1459 |
+
const data = await apiRequest(`/api/market-peers?symbol=${symbol}`);
|
| 1460 |
+
renderOverview(`${data.category.toUpperCase()} LIÊN QUAN`, data.peers);
|
| 1461 |
+
} catch (e) {
|
| 1462 |
+
console.warn('Overview fetch failed', e);
|
| 1463 |
+
}
|
| 1464 |
+
}
|
| 1465 |
+
|
| 1466 |
+
function renderOverview(categoryLabel, peers) {
|
| 1467 |
+
marketOverview.innerHTML = `
|
| 1468 |
+
<div style="font-size:0.65rem; color:var(--txt-muted); margin-bottom:8px; padding-left:12px;">${categoryLabel}</div>
|
| 1469 |
+
${peers.map(p => `
|
| 1470 |
+
<div class="market-item ${p.symbol === currentSymbol ? 'active' : ''}" onclick="switchSymbol('${p.symbol}')" id="overview-${p.symbol}">
|
| 1471 |
+
<div class="m-sym">
|
| 1472 |
+
<span class="m-ticker">${p.symbol}</span>
|
| 1473 |
+
<span class="m-name">${p.name}</span>
|
| 1474 |
+
</div>
|
| 1475 |
+
<div class="m-price-box">
|
| 1476 |
+
<div class="m-price">${formatPrice(p.price)}</div>
|
| 1477 |
+
<div class="m-change ${p.change_24h >= 0 ? 'up' : 'down'}">${formatPct(p.change_24h)}</div>
|
| 1478 |
+
</div>
|
| 1479 |
+
</div>
|
| 1480 |
+
`).join('')}
|
| 1481 |
+
`;
|
| 1482 |
+
}
|
| 1483 |
+
|
| 1484 |
+
function updateOverviewItem(data) {
|
| 1485 |
+
const itemPrice = document.querySelector(`#overview-${data.symbol} .m-price`);
|
| 1486 |
+
const itemChange = document.querySelector(`#overview-${data.symbol} .m-change`);
|
| 1487 |
+
if (itemPrice) itemPrice.textContent = formatPrice(data.price);
|
| 1488 |
+
if (itemChange) {
|
| 1489 |
+
itemChange.textContent = formatPct(data.change_pct);
|
| 1490 |
+
itemChange.className = `m-change ${data.change_pct >= 0 ? 'up' : 'down'}`;
|
| 1491 |
+
}
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
/* ── Search Logic ───────────────────────────── */
|
| 1495 |
+
let searchTimeout = null;
|
| 1496 |
+
symbolSearch.oninput = (e) => {
|
| 1497 |
+
clearTimeout(searchTimeout);
|
| 1498 |
+
const q = e.target.value.trim();
|
| 1499 |
+
if (!q) {
|
| 1500 |
+
searchResults.classList.remove('visible');
|
| 1501 |
+
return;
|
| 1502 |
+
}
|
| 1503 |
+
searchTimeout = setTimeout(async () => {
|
| 1504 |
+
try {
|
| 1505 |
+
const data = await apiRequest(`/api/search?q=${encodeURIComponent(q)}`);
|
| 1506 |
+
renderSearchResults(data.results);
|
| 1507 |
+
} catch (e) {
|
| 1508 |
+
console.error('Search failed', e);
|
| 1509 |
+
}
|
| 1510 |
+
}, 300);
|
| 1511 |
+
};
|
| 1512 |
+
|
| 1513 |
+
function renderSearchResults(results) {
|
| 1514 |
+
if (!results.length) {
|
| 1515 |
+
searchResults.classList.remove('visible');
|
| 1516 |
+
return;
|
| 1517 |
+
}
|
| 1518 |
+
searchResults.innerHTML = results.map(r => `
|
| 1519 |
+
<div class="search-item" onclick="switchSymbol('${r.symbol}')">
|
| 1520 |
+
<span class="sym">${r.symbol}</span>
|
| 1521 |
+
<span class="name">${r.label}</span>
|
| 1522 |
+
</div>
|
| 1523 |
+
`).join('');
|
| 1524 |
+
searchResults.classList.add('visible');
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
+
/* ── Switch Logic ───────────────────────────── */
|
| 1528 |
+
async function switchSymbol(symbol) {
|
| 1529 |
+
currentSymbol = symbol;
|
| 1530 |
+
symbolSearch.value = symbol;
|
| 1531 |
+
searchResults.classList.remove('visible');
|
| 1532 |
+
|
| 1533 |
+
// Update labels immediately for better UX responsiveness
|
| 1534 |
+
const sLabel = symbolMap.get(symbol) || symbol;
|
| 1535 |
+
updateStatus(`${sLabel} · ${currentInterval} — Đang nạp...`, 'loading');
|
| 1536 |
+
|
| 1537 |
+
// Update active state in sidebar
|
| 1538 |
+
document.querySelectorAll('.market-item').forEach(el => el.classList.remove('active'));
|
| 1539 |
+
const activeItem = document.getElementById(`overview-${symbol}`);
|
| 1540 |
+
if (activeItem) activeItem.classList.add('active');
|
| 1541 |
+
|
| 1542 |
+
await refreshChart();
|
| 1543 |
+
refreshOverview();
|
| 1544 |
+
connectWS(symbol);
|
| 1545 |
+
}
|
| 1546 |
+
|
| 1547 |
+
/* ── Chart init ────────────────────────────── */
|
| 1548 |
+
const chartEl = document.getElementById('chart');
|
| 1549 |
+
|
| 1550 |
+
const chart = LightweightCharts.createChart(chartEl, {
|
| 1551 |
+
layout: {
|
| 1552 |
+
background: { type: 'solid', color: 'transparent' },
|
| 1553 |
+
textColor: 'rgba(100, 150, 200, 0.85)',
|
| 1554 |
+
fontSize: 11,
|
| 1555 |
+
fontFamily: "'Space Mono', 'Courier New', monospace",
|
| 1556 |
+
},
|
| 1557 |
+
grid: {
|
| 1558 |
+
vertLines: { visible: false },
|
| 1559 |
+
horzLines: { visible: false },
|
| 1560 |
+
},
|
| 1561 |
+
rightPriceScale: {
|
| 1562 |
+
borderColor: 'rgba(40, 80, 140, 0.25)',
|
| 1563 |
+
autoScale: true,
|
| 1564 |
+
scaleMargins: { top: 0.08, bottom: 0.08 },
|
| 1565 |
+
},
|
| 1566 |
+
timeScale: {
|
| 1567 |
+
borderColor: 'rgba(40, 80, 140, 0.25)',
|
| 1568 |
+
timeVisible: true,
|
| 1569 |
+
secondsVisible: false,
|
| 1570 |
+
fixLeftEdge: false,
|
| 1571 |
+
fixRightEdge: false,
|
| 1572 |
+
},
|
| 1573 |
+
crosshair: {
|
| 1574 |
+
mode: LightweightCharts.CrosshairMode.Normal,
|
| 1575 |
+
vertLine: {
|
| 1576 |
+
color: 'rgba(34, 211, 238, 0.35)',
|
| 1577 |
+
width: 1,
|
| 1578 |
+
labelBackgroundColor: '#040d1e',
|
| 1579 |
+
},
|
| 1580 |
+
horzLine: {
|
| 1581 |
+
color: 'rgba(34, 211, 238, 0.35)',
|
| 1582 |
+
width: 1,
|
| 1583 |
+
labelBackgroundColor: '#040d1e',
|
| 1584 |
+
},
|
| 1585 |
+
},
|
| 1586 |
+
watermark: {
|
| 1587 |
+
visible: true,
|
| 1588 |
+
fontSize: 64,
|
| 1589 |
+
horzAlign: 'center',
|
| 1590 |
+
vertAlign: 'center',
|
| 1591 |
+
color: 'rgba(34, 211, 238, 0.04)',
|
| 1592 |
+
text: 'Super AI Analysis',
|
| 1593 |
+
},
|
| 1594 |
+
handleScroll: true,
|
| 1595 |
+
handleScale: true,
|
| 1596 |
+
});
|
| 1597 |
+
|
| 1598 |
+
/* ── Series ────────────────────────────────── */
|
| 1599 |
+
const candleSeries = chart.addCandlestickSeries({
|
| 1600 |
+
upColor: '#1dba8a',
|
| 1601 |
+
downColor: '#e05560',
|
| 1602 |
+
borderVisible: false,
|
| 1603 |
+
wickUpColor: '#1dba8a',
|
| 1604 |
+
wickDownColor: '#e05560',
|
| 1605 |
+
});
|
| 1606 |
+
|
| 1607 |
+
const p50Series = chart.addLineSeries({
|
| 1608 |
+
color: '#22d3ee',
|
| 1609 |
+
lineWidth: 2,
|
| 1610 |
+
title: 'Dự báo AI',
|
| 1611 |
+
priceLineVisible: false,
|
| 1612 |
+
lastValueVisible: true,
|
| 1613 |
+
visible: false,
|
| 1614 |
+
});
|
| 1615 |
+
|
| 1616 |
+
const p10Series = chart.addLineSeries({
|
| 1617 |
+
color: 'rgba(34, 211, 238, 0.22)',
|
| 1618 |
+
lineWidth: 1,
|
| 1619 |
+
lineStyle: LightweightCharts.LineStyle.Dashed,
|
| 1620 |
+
priceLineVisible: false,
|
| 1621 |
+
lastValueVisible: false,
|
| 1622 |
+
visible: false,
|
| 1623 |
+
});
|
| 1624 |
+
|
| 1625 |
+
const p90Series = chart.addLineSeries({
|
| 1626 |
+
color: 'rgba(34, 211, 238, 0.22)',
|
| 1627 |
+
lineWidth: 1,
|
| 1628 |
+
lineStyle: LightweightCharts.LineStyle.Dashed,
|
| 1629 |
+
priceLineVisible: false,
|
| 1630 |
+
lastValueVisible: false,
|
| 1631 |
+
visible: false,
|
| 1632 |
+
});
|
| 1633 |
+
|
| 1634 |
+
/* ── Indicator Series ──────────────────────── */
|
| 1635 |
+
const bbMiddleSeries = chart.addLineSeries({ color: 'rgba(255, 255, 255, 0.2)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false });
|
| 1636 |
+
const bbUpperSeries = chart.addLineSeries({ color: 'rgba(34, 211, 238, 0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false });
|
| 1637 |
+
const bbLowerSeries = chart.addLineSeries({ color: 'rgba(34, 211, 238, 0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false });
|
| 1638 |
+
|
| 1639 |
+
let rsiSeries = null;
|
| 1640 |
+
|
| 1641 |
+
/* ── Status helpers ────────────────────────── */
|
| 1642 |
+
/* ── UI Sync & Loader helpers ──────────────── */
|
| 1643 |
+
function clearAllOverlays() {
|
| 1644 |
+
// Unify state by hiding all primary overlays
|
| 1645 |
+
terminalLoader.classList.remove('active');
|
| 1646 |
+
analysisPanel.classList.remove('active');
|
| 1647 |
+
searchResults.classList.remove('visible');
|
| 1648 |
+
}
|
| 1649 |
+
|
| 1650 |
+
function showLoader(msg = 'Đang tải dữ liệu') {
|
| 1651 |
+
loaderText.textContent = msg;
|
| 1652 |
+
terminalLoader.classList.add('active');
|
| 1653 |
+
}
|
| 1654 |
+
|
| 1655 |
+
function hideLoader() {
|
| 1656 |
+
terminalLoader.classList.remove('active');
|
| 1657 |
+
}
|
| 1658 |
+
|
| 1659 |
+
function updateStatus(msg, type = '') {
|
| 1660 |
+
statusText.textContent = msg;
|
| 1661 |
+
statusDot.className = 'dot ' + type;
|
| 1662 |
+
statusPill.className = 'status-pill ' + type;
|
| 1663 |
+
}
|
| 1664 |
+
|
| 1665 |
+
function formatPrice(value) {
|
| 1666 |
+
if (value === null || value === undefined || Number.isNaN(Number(value))) return '--';
|
| 1667 |
+
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 4 });
|
| 1668 |
+
}
|
| 1669 |
+
|
| 1670 |
+
function formatPct(value) {
|
| 1671 |
+
if (value === null || value === undefined || Number.isNaN(Number(value))) return '--';
|
| 1672 |
+
const num = Number(value);
|
| 1673 |
+
const sign = num > 0 ? '+' : '';
|
| 1674 |
+
return `${sign}${num.toFixed(2)}%`;
|
| 1675 |
+
}
|
| 1676 |
+
|
| 1677 |
+
function formatRR(value) {
|
| 1678 |
+
if (value === null || value === undefined || Number.isNaN(Number(value))) return '--';
|
| 1679 |
+
return `${Number(value).toFixed(2)}R`;
|
| 1680 |
+
}
|
| 1681 |
+
|
| 1682 |
+
/* ── State for cached analysis data ──────────── */
|
| 1683 |
+
let lastAnalysisPayload = null;
|
| 1684 |
+
let lastAnalysisSymbol = null;
|
| 1685 |
+
let lastAnalysisInterval = null;
|
| 1686 |
+
|
| 1687 |
+
/* ── Fullscreen Dashboard v6.0 — 3 Gauge Hero ──── */
|
| 1688 |
+
function renderAnalysisPanel(symbol, interval, payload) {
|
| 1689 |
+
// Cache the payload for toggle reuse
|
| 1690 |
+
if (payload?.analysis) {
|
| 1691 |
+
lastAnalysisPayload = payload;
|
| 1692 |
+
lastAnalysisSymbol = symbol;
|
| 1693 |
+
lastAnalysisInterval = interval;
|
| 1694 |
+
}
|
| 1695 |
+
|
| 1696 |
+
if (!payload?.analysis) {
|
| 1697 |
+
analysisPanel.innerHTML = `
|
| 1698 |
+
<div class="dash-loading">
|
| 1699 |
+
<div class="loader-ring" style="width:42px;height:42px;border-radius:50%;border:1.5px solid rgba(40,80,140,0.15);border-top-color:var(--accent);animation:spin 0.8s linear infinite;"></div>
|
| 1700 |
+
<p>Nhấn "Phân tích" để hiển thị bảng phân tích kỹ thuật</p>
|
| 1701 |
+
</div>
|
| 1702 |
+
`;
|
| 1703 |
+
return;
|
| 1704 |
+
}
|
| 1705 |
+
|
| 1706 |
+
const a = payload.analysis;
|
| 1707 |
+
if (!a.oscillators && !a.moving_averages) {
|
| 1708 |
+
analysisPanel.innerHTML = '<div class="dash-loading"><div class="loader-ring" style="width:42px;height:42px;border-radius:50%;border:1.5px solid rgba(40,80,140,0.15);border-top-color:var(--accent);animation:spin 0.8s linear infinite;"></div><p>Đang tính toán...</p></div>';
|
| 1709 |
+
return;
|
| 1710 |
+
}
|
| 1711 |
+
|
| 1712 |
+
const osc = a.oscillators || { sell:0, neutral:0, buy:0, signal:'--', data:[] };
|
| 1713 |
+
const ma = a.moving_averages || { sell:0, neutral:0, buy:0, signal:'--', data:[] };
|
| 1714 |
+
const summary = a.summary || { sell:0, neutral:0, buy:0, signal:'--' };
|
| 1715 |
+
const pivots = (a.pivot_points || {}).data || [];
|
| 1716 |
+
|
| 1717 |
+
// ── AI Forecast calculation ──
|
| 1718 |
+
const confidence = payload.ensemble?.confidence ?? 0;
|
| 1719 |
+
const forecastRows = payload.forecast || [];
|
| 1720 |
+
const lastClose = payload.last_close || 0;
|
| 1721 |
+
let forecastEnd = lastClose;
|
| 1722 |
+
if (forecastRows.length > 1) {
|
| 1723 |
+
forecastEnd = forecastRows[forecastRows.length - 1]?.p50 ?? lastClose;
|
| 1724 |
+
}
|
| 1725 |
+
const forecastPctChange = lastClose > 0 ? ((forecastEnd - lastClose) / lastClose) * 100 : 0;
|
| 1726 |
+
|
| 1727 |
+
// ── Compute AI Forecast as Buy/Sell gauge ──
|
| 1728 |
+
// Map forecast % change to a score: >0 = buy, <0 = sell
|
| 1729 |
+
// Strength determines strong/weak
|
| 1730 |
+
const absChange = Math.abs(forecastPctChange);
|
| 1731 |
+
let aiBuy = 0, aiSell = 0, aiNeutral = 0;
|
| 1732 |
+
if (forecastPctChange > 0.5) {
|
| 1733 |
+
aiBuy = absChange > 2 ? 2 : 1;
|
| 1734 |
+
aiNeutral = absChange > 2 ? 0 : 1;
|
| 1735 |
+
} else if (forecastPctChange < -0.5) {
|
| 1736 |
+
aiSell = absChange > 2 ? 2 : 1;
|
| 1737 |
+
aiNeutral = absChange > 2 ? 0 : 1;
|
| 1738 |
+
} else {
|
| 1739 |
+
aiNeutral = 1;
|
| 1740 |
+
}
|
| 1741 |
+
const aiTotal = aiBuy + aiSell + aiNeutral;
|
| 1742 |
+
let aiSignal = 'Trung lập';
|
| 1743 |
+
if (forecastPctChange > 2) aiSignal = 'Mua mạnh';
|
| 1744 |
+
else if (forecastPctChange > 0.5) aiSignal = 'Mua';
|
| 1745 |
+
else if (forecastPctChange < -2) aiSignal = 'Bán mạnh';
|
| 1746 |
+
else if (forecastPctChange < -0.5) aiSignal = 'Bán';
|
| 1747 |
+
|
| 1748 |
+
// ── TỔNG KẾT = (Phân tích kỹ thuật + Dự báo AI) / 2 ──
|
| 1749 |
+
const techTotal = (summary.buy + summary.sell + summary.neutral) || 1;
|
| 1750 |
+
const techScore = (summary.buy - summary.sell) / techTotal; // -1 to +1
|
| 1751 |
+
const aiScore = forecastPctChange > 0.5 ? Math.min(forecastPctChange / 5, 1) :
|
| 1752 |
+
forecastPctChange < -0.5 ? Math.max(forecastPctChange / 5, -1) : 0;
|
| 1753 |
+
const combinedScore = (techScore + aiScore) / 2; // -1 to +1
|
| 1754 |
+
|
| 1755 |
+
// Map combined score to buy/sell/neutral counts for gauge
|
| 1756 |
+
let totalBuy = 0, totalSell = 0, totalNeutral = 0;
|
| 1757 |
+
if (combinedScore > 0.15) { totalBuy = Math.round(Math.abs(combinedScore) * 10); totalNeutral = 10 - totalBuy; }
|
| 1758 |
+
else if (combinedScore < -0.15) { totalSell = Math.round(Math.abs(combinedScore) * 10); totalNeutral = 10 - totalSell; }
|
| 1759 |
+
else { totalNeutral = 10; }
|
| 1760 |
+
|
| 1761 |
+
let totalSignal = 'Trung lập';
|
| 1762 |
+
if (combinedScore > 0.4) totalSignal = 'Mua mạnh';
|
| 1763 |
+
else if (combinedScore > 0.15) totalSignal = 'Mua';
|
| 1764 |
+
else if (combinedScore < -0.4) totalSignal = 'Bán mạnh';
|
| 1765 |
+
else if (combinedScore < -0.15) totalSignal = 'Bán';
|
| 1766 |
+
|
| 1767 |
+
// ── Big SVG Gauge builder (Fix Clipping + Add Value) ──
|
| 1768 |
+
function buildBigGauge(sell, neutral, buy, signal, w=320, h=220) {
|
| 1769 |
+
const total = sell + neutral + buy || 1;
|
| 1770 |
+
const score = (buy - sell) / total;
|
| 1771 |
+
const displayValue = Math.round((score + 1) * 50);
|
| 1772 |
+
const angle = score * 135;
|
| 1773 |
+
const cx = w/2, cy = h * 0.52, r = (w/2) * 0.72; // Adjusted r and cy to prevent clipping
|
| 1774 |
+
const strokeW = 18;
|
| 1775 |
+
function arc(s, e, col) {
|
| 1776 |
+
const sa = (s-90)*Math.PI/180, ea = (e-90)*Math.PI/180;
|
| 1777 |
+
const x1=cx+r*Math.cos(sa), y1=cy+r*Math.sin(sa), x2=cx+r*Math.cos(ea), y2=cy+r*Math.sin(ea);
|
| 1778 |
+
return `<path d="M${x1},${y1} A${r},${r} 0 ${(e-s)>180?1:0} 1 ${x2},${y2}" fill="none" stroke="${col}" stroke-width="${strokeW}" stroke-linecap="round" opacity="0.8"/>`;
|
| 1779 |
+
}
|
| 1780 |
+
// Needle
|
| 1781 |
+
const na = (angle-90)*Math.PI/180, nl = r + 5;
|
| 1782 |
+
const nx = cx+nl*Math.cos(na), ny = cy+nl*Math.sin(na);
|
| 1783 |
+
// Needle dynamic color
|
| 1784 |
+
const needleColor = score > 0.3 ? '#4ade80' : score > 0.1 ? '#bbf7d0' : score < -0.3 ? '#f87171' : score < -0.1 ? '#fecaca' : '#f1f5f9';
|
| 1785 |
+
|
| 1786 |
+
return `
|
| 1787 |
+
<div class="gauge-hero-value-box">
|
| 1788 |
+
<div class="gauge-hero-value-num">${displayValue}</div>
|
| 1789 |
+
<div class="gauge-hero-value-label">Chỉ số</div>
|
| 1790 |
+
</div>
|
| 1791 |
+
<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
| 1792 |
+
<defs>
|
| 1793 |
+
<filter id="needle-glow"><feGaussianBlur stdDeviation="6" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
| 1794 |
+
</defs>
|
| 1795 |
+
<!-- Arc segments -->
|
| 1796 |
+
${arc(-135,-81,'#ef4444')}${arc(-81,-27,'#fca5a5')}${arc(-27,27,'#f1f5f9')}${arc(27,81,'#bbf7d0')}${arc(81,135,'#22c55e')}
|
| 1797 |
+
<!-- Labels -->
|
| 1798 |
+
<text x="${cx + r*Math.cos((-135-90)*Math.PI/180)*1.35}" y="${cy + r*Math.sin((-135-90)*Math.PI/180)*1.35}" fill="rgba(255,255,255,0.3)" font-size="10" text-anchor="middle" font-family="var(--ff-display)" font-weight="900">BÁN</text>
|
| 1799 |
+
<text x="${cx + r*Math.cos((135-90)*Math.PI/180)*1.35}" y="${cy + r*Math.sin((135-90)*Math.PI/180)*1.35}" fill="rgba(255,255,255,0.3)" font-size="10" text-anchor="middle" font-family="var(--ff-display)" font-weight="900">MUA</text>
|
| 1800 |
+
<!-- Needle -->
|
| 1801 |
+
<line x1="${cx}" y1="${cy}" x2="${nx}" y2="${ny}" stroke="${needleColor}" stroke-width="7" stroke-linecap="round" filter="url(#needle-glow)"/>
|
| 1802 |
+
<circle cx="${cx}" cy="${cy}" r="14" fill="${needleColor}" />
|
| 1803 |
+
<circle cx="${cx}" cy="${cy}" r="7" fill="#fff" />
|
| 1804 |
+
</svg>
|
| 1805 |
+
`;
|
| 1806 |
+
}
|
| 1807 |
+
|
| 1808 |
+
function signalClass(signal) {
|
| 1809 |
+
if (signal.includes('Mua mạnh')) return 'strong-buy';
|
| 1810 |
+
if (signal.includes('Mua')) return 'buy';
|
| 1811 |
+
if (signal.includes('Bán mạnh')) return 'strong-sell';
|
| 1812 |
+
if (signal.includes('Bán')) return 'sell';
|
| 1813 |
+
return 'neutral';
|
| 1814 |
+
}
|
| 1815 |
+
|
| 1816 |
+
function actCls(act) {
|
| 1817 |
+
return act === 'Mua' ? 'dt-act-buy' : act === 'Bán' ? 'dt-act-sell' : 'dt-act-neut';
|
| 1818 |
+
}
|
| 1819 |
+
|
| 1820 |
+
const oscRows = osc.data.map(d => `<tr><td>${d.name}</td><td class="dt-val">${d.value !== null ? d.value : '—'}</td><td class="dt-act ${actCls(d.action)}">${d.action}</td></tr>`).join('');
|
| 1821 |
+
const maRows = ma.data.map(d => `<tr><td>${d.name}</td><td class="dt-val">${d.value !== null ? d.value : '—'}</td><td class="dt-act ${actCls(d.action)}">${d.action}</td></tr>`).join('');
|
| 1822 |
+
const pivotRows = pivots.map(p => `<tr><td>${p.level}</td><td>${p.classic ?? '—'}</td><td>${p.fibonacci ?? '—'}</td><td>${p.camarilla ?? '—'}</td><td>${p.woodie ?? '—'}</td><td>${p.dm ?? '—'}</td></tr>`).join('');
|
| 1823 |
+
|
| 1824 |
+
const sLabel = symbolMap.get(symbol) || symbol;
|
| 1825 |
+
const tLabel = timeframeMap[interval] || interval;
|
| 1826 |
+
|
| 1827 |
+
analysisPanel.innerHTML = `
|
| 1828 |
+
<!-- Dashboard Header -->
|
| 1829 |
+
<div class="dash-header">
|
| 1830 |
+
<div class="dash-header-left">
|
| 1831 |
+
<div class="dash-logo-dot"></div>
|
| 1832 |
+
<span class="dash-title">${sLabel}</span>
|
| 1833 |
+
<span class="dash-subtitle">· ${tLabel} · Bảng Phân tích Tổng hợp</span>
|
| 1834 |
+
</div>
|
| 1835 |
+
<div class="dash-close" id="dashCloseBtn" title="Đóng">
|
| 1836 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
| 1837 |
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
| 1838 |
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
| 1839 |
+
</svg>
|
| 1840 |
+
</div>
|
| 1841 |
+
</div>
|
| 1842 |
+
|
| 1843 |
+
<!-- Dashboard Body -->
|
| 1844 |
+
<div class="dash-body">
|
| 1845 |
+
|
| 1846 |
+
<!-- TOP ROW: 3 Big Gauges -->
|
| 1847 |
+
<div class="dash-gauges-hero">
|
| 1848 |
+
|
| 1849 |
+
<!-- Gauge 1: Phân tích kỹ thuật (was TỔNG KẾT) -->
|
| 1850 |
+
<div class="gauge-hero-card">
|
| 1851 |
+
<div class="gauge-hero-title">PHÂN TÍCH KỸ THUẬT</div>
|
| 1852 |
+
<div class="gauge-hero-svg-wrap">
|
| 1853 |
+
${buildBigGauge(summary.sell, summary.neutral, summary.buy, summary.signal)}
|
| 1854 |
+
</div>
|
| 1855 |
+
<div class="gauge-hero-signal ${signalClass(summary.signal)}">${summary.signal}</div>
|
| 1856 |
+
<div class="gauge-hero-counts">
|
| 1857 |
+
<span><span class="ghc-label">Bán</span><span class="ghc-value">${summary.sell}</span></span>
|
| 1858 |
+
<span><span class="ghc-label">Trung lập</span><span class="ghc-value">${summary.neutral}</span></span>
|
| 1859 |
+
<span><span class="ghc-label">Mua</span><span class="ghc-value">${summary.buy}</span></span>
|
| 1860 |
+
</div>
|
| 1861 |
+
</div>
|
| 1862 |
+
|
| 1863 |
+
<!-- Gauge 2: Dự báo AI (Buy/Sell gauge) -->
|
| 1864 |
+
<div class="gauge-hero-card">
|
| 1865 |
+
<div class="gauge-hero-title">DỰ BÁO AI</div>
|
| 1866 |
+
<div class="gauge-hero-svg-wrap">
|
| 1867 |
+
${buildBigGauge(aiSell, aiNeutral, aiBuy, aiSignal)}
|
| 1868 |
+
</div>
|
| 1869 |
+
<div class="gauge-hero-signal ${signalClass(aiSignal)}">${aiSignal}</div>
|
| 1870 |
+
</div>
|
| 1871 |
+
|
| 1872 |
+
<!-- Gauge 3: TỔNG KẾT = (Phân tích KT + Dự báo AI) / 2 -->
|
| 1873 |
+
<div class="gauge-hero-card hero-total">
|
| 1874 |
+
<div class="gauge-hero-title title-total">⚡ TỔNG KẾT</div>
|
| 1875 |
+
<div class="gauge-hero-svg-wrap">
|
| 1876 |
+
${buildBigGauge(totalSell, totalNeutral, totalBuy, totalSignal)}
|
| 1877 |
+
</div>
|
| 1878 |
+
<div class="gauge-hero-signal signal-total ${signalClass(totalSignal)}">${totalSignal}</div>
|
| 1879 |
+
</div>
|
| 1880 |
+
|
| 1881 |
+
</div>
|
| 1882 |
+
|
| 1883 |
+
<!-- BOTTOM: Data Tables -->
|
| 1884 |
+
<div class="dash-tables-row">
|
| 1885 |
+
|
| 1886 |
+
<!-- Oscillators -->
|
| 1887 |
+
<div class="dash-col">
|
| 1888 |
+
<div class="dc-header">Chỉ số Dao động</div>
|
| 1889 |
+
<div class="dash-table-wrap">
|
| 1890 |
+
<table class="dt"><tbody>${oscRows}</tbody></table>
|
| 1891 |
+
</div>
|
| 1892 |
+
</div>
|
| 1893 |
+
|
| 1894 |
+
<!-- Moving Averages -->
|
| 1895 |
+
<div class="dash-col">
|
| 1896 |
+
<div class="dc-header">Trung bình trượt</div>
|
| 1897 |
+
<div class="dash-table-wrap">
|
| 1898 |
+
<table class="dt"><tbody>${maRows}</tbody></table>
|
| 1899 |
+
</div>
|
| 1900 |
+
</div>
|
| 1901 |
+
|
| 1902 |
+
<!-- Pivot Points -->
|
| 1903 |
+
<div class="dash-col col-pivots">
|
| 1904 |
+
<div class="dc-header">Điểm xoay</div>
|
| 1905 |
+
<div class="dash-table-wrap">
|
| 1906 |
+
<table class="pivot-table">
|
| 1907 |
+
<thead><tr><th>Mức</th><th>CL</th><th>FB</th><th>CM</th><th>WD</th><th>DM</th></tr></thead>
|
| 1908 |
+
<tbody>${pivotRows}</tbody>
|
| 1909 |
+
</table>
|
| 1910 |
+
</div>
|
| 1911 |
+
</div>
|
| 1912 |
+
|
| 1913 |
+
</div>
|
| 1914 |
+
|
| 1915 |
+
<div class="summary-disclaimer">
|
| 1916 |
+
<strong>⚠ Cảnh báo</strong> — Thông tin phân tích kỹ thuật này không phải lời khuyên đầu tư. Hãy luôn quản lý rủi ro.
|
| 1917 |
+
</div>
|
| 1918 |
+
|
| 1919 |
+
</div>
|
| 1920 |
+
`;
|
| 1921 |
+
|
| 1922 |
+
// Close logic
|
| 1923 |
+
const closeBtn = document.getElementById('dashCloseBtn');
|
| 1924 |
+
if (closeBtn) closeBtn.onclick = () => analysisPanel.classList.remove('active');
|
| 1925 |
+
}
|
| 1926 |
+
|
| 1927 |
+
|
| 1928 |
+
|
| 1929 |
+
/* ── Clear chart immediately on switch ─────── */
|
| 1930 |
+
function clearChartImmediate() {
|
| 1931 |
+
candleSeries.setData([]);
|
| 1932 |
+
p50Series.setData([]);
|
| 1933 |
+
p10Series.setData([]);
|
| 1934 |
+
p90Series.setData([]);
|
| 1935 |
+
|
| 1936 |
+
p50Series.applyOptions({ visible: false });
|
| 1937 |
+
p10Series.applyOptions({ visible: false });
|
| 1938 |
+
p90Series.applyOptions({ visible: false });
|
| 1939 |
+
|
| 1940 |
+
// Clear indicators
|
| 1941 |
+
bbMiddleSeries.setData([]);
|
| 1942 |
+
bbUpperSeries.setData([]);
|
| 1943 |
+
bbLowerSeries.setData([]);
|
| 1944 |
+
if (rsiSeries) rsiSeries.setData([]);
|
| 1945 |
+
|
| 1946 |
+
// Hide dashboard and clear cached data on symbol switch
|
| 1947 |
+
analysisPanel.classList.remove('active');
|
| 1948 |
+
lastAnalysisPayload = null;
|
| 1949 |
+
lastAnalysisSymbol = null;
|
| 1950 |
+
chart.priceScale('right').applyOptions({ autoScale: true });
|
| 1951 |
+
|
| 1952 |
+
const symbol = currentSymbol;
|
| 1953 |
+
const interval = timeframeSelect.value;
|
| 1954 |
+
if (symbol && interval) {
|
| 1955 |
+
fetch(`${API_BASE}/api/switch`, {
|
| 1956 |
+
method: 'POST',
|
| 1957 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1958 |
+
body: JSON.stringify({ symbol, interval }),
|
| 1959 |
+
}).catch(e => console.warn('[switch] failed', e));
|
| 1960 |
+
}
|
| 1961 |
+
}
|
| 1962 |
+
|
| 1963 |
+
/* ── API helper ────────────────────────────── */
|
| 1964 |
+
async function apiRequest(path) {
|
| 1965 |
+
const sep = path.includes('?') ? '&' : '?';
|
| 1966 |
+
const url = `${API_BASE}${path}${sep}_t=${Date.now()}`;
|
| 1967 |
+
|
| 1968 |
+
const options = { method: 'GET' };
|
| 1969 |
+
if (fetchController) options.signal = fetchController.signal;
|
| 1970 |
+
|
| 1971 |
+
const resp = await fetch(url, options);
|
| 1972 |
+
if (!resp.ok) {
|
| 1973 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 1974 |
+
throw new Error(typeof err.detail === 'object'
|
| 1975 |
+
? err.detail.message || JSON.stringify(err.detail)
|
| 1976 |
+
: (err.detail || 'Lỗi kết nối'));
|
| 1977 |
+
}
|
| 1978 |
+
return resp.json();
|
| 1979 |
+
}
|
| 1980 |
+
|
| 1981 |
+
/* ── Load symbol list metadata ─────────────── */
|
| 1982 |
+
async function loadSymbols() {
|
| 1983 |
+
try {
|
| 1984 |
+
const data = await apiRequest('/api/symbols');
|
| 1985 |
+
data.symbols.forEach(s => symbolMap.set(s.symbol, s.label));
|
| 1986 |
+
// Default overview refresh
|
| 1987 |
+
refreshOverview();
|
| 1988 |
+
} catch (e) {
|
| 1989 |
+
updateStatus('Lỗi nạp metadata: ' + e.message, 'error');
|
| 1990 |
+
}
|
| 1991 |
+
}
|
| 1992 |
+
|
| 1993 |
+
/* ── Main refresh ──────────────────────────── */
|
| 1994 |
+
async function refreshChart() {
|
| 1995 |
+
const symbol = currentSymbol;
|
| 1996 |
+
const interval = timeframeSelect.value;
|
| 1997 |
+
const horizon = Math.max(5, Math.min(300, parseInt(horizonInput.value) || 10));
|
| 1998 |
+
if (!symbol) return;
|
| 1999 |
+
|
| 2000 |
+
// New: Abort previous requests
|
| 2001 |
+
if (fetchController) fetchController.abort();
|
| 2002 |
+
fetchController = new AbortController();
|
| 2003 |
+
|
| 2004 |
+
refreshBtn.disabled = true;
|
| 2005 |
+
// Don't auto-show dashboard panel on chart refresh
|
| 2006 |
+
updateStatus(`${symbol} · ${interval} — Đang nạp dữ liệu...`, 'loading');
|
| 2007 |
+
|
| 2008 |
+
showLoader('Đang nạp dữ liệu...');
|
| 2009 |
+
|
| 2010 |
+
/* Update chart watermark */
|
| 2011 |
+
const sLabel = symbolMap.get(symbol) || symbol;
|
| 2012 |
+
const tLabel = timeframeMap[interval] || interval;
|
| 2013 |
+
chart.applyOptions({
|
| 2014 |
+
watermark: {
|
| 2015 |
+
text: `${sLabel} · ${tLabel}`,
|
| 2016 |
+
color: 'rgba(34, 211, 238, 0.27)',
|
| 2017 |
+
fontSize: 72,
|
| 2018 |
+
},
|
| 2019 |
+
});
|
| 2020 |
+
|
| 2021 |
+
clearChartImmediate();
|
| 2022 |
+
|
| 2023 |
+
// Start fetching Stage 1 (Fast: OHLCV + Indicators)
|
| 2024 |
+
try {
|
| 2025 |
+
const [histData, indData] = await Promise.all([
|
| 2026 |
+
apiRequest(`/api/historical/${encodeURIComponent(symbol)}?interval=${interval}&limit=500`),
|
| 2027 |
+
apiRequest(`/api/indicators/${encodeURIComponent(symbol)}?interval=${interval}&limit=500`)
|
| 2028 |
+
]);
|
| 2029 |
+
|
| 2030 |
+
if (histData.data.length > 0) {
|
| 2031 |
+
candleSeries.setData(histData.data);
|
| 2032 |
+
lastCandleData = histData.data[histData.data.length - 1];
|
| 2033 |
+
}
|
| 2034 |
+
|
| 2035 |
+
/* ── Indicators ── */
|
| 2036 |
+
const type = indicatorSelect.value;
|
| 2037 |
+
const indicators = indData.indicators || {};
|
| 2038 |
+
const series = indicators.series || {};
|
| 2039 |
+
|
| 2040 |
+
if (type === 'bb' || type === 'both') {
|
| 2041 |
+
if (series.bb_upper) bbUpperSeries.setData(series.bb_upper);
|
| 2042 |
+
if (series.bb_mid) bbMiddleSeries.setData(series.bb_mid);
|
| 2043 |
+
if (series.bb_lower) bbLowerSeries.setData(series.bb_lower);
|
| 2044 |
+
}
|
| 2045 |
+
|
| 2046 |
+
// Add RSI update if needed
|
| 2047 |
+
|
| 2048 |
+
chart.timeScale().fitContent();
|
| 2049 |
+
hideLoader();
|
| 2050 |
+
updateStatus(`${symbol} · ${interval} | Đang nạp AI...`, 'loading');
|
| 2051 |
+
|
| 2052 |
+
// Trigger Stage 2 in background (Slow: AI Forecast)
|
| 2053 |
+
fetchAIAnalysis(symbol, interval);
|
| 2054 |
+
|
| 2055 |
+
} catch (e) {
|
| 2056 |
+
if (e.name === 'AbortError') return; // Ignore canceled requests
|
| 2057 |
+
console.error('Stage 1 Fetch Error:', e);
|
| 2058 |
+
updateStatus('Lỗi nạp dữ liệu: ' + e.message, 'error');
|
| 2059 |
+
} finally {
|
| 2060 |
+
refreshBtn.disabled = false;
|
| 2061 |
+
hideLoader();
|
| 2062 |
+
}
|
| 2063 |
+
}
|
| 2064 |
+
|
| 2065 |
+
/* ── Stage 2: Background AI Forecast ───────────────── */
|
| 2066 |
+
async function fetchAIAnalysis(symbol, interval) {
|
| 2067 |
+
if (currentSymbol !== symbol) return; // Prevent race conditions
|
| 2068 |
+
|
| 2069 |
+
const panel = document.getElementById('analysisPanel');
|
| 2070 |
+
panel.innerHTML = `
|
| 2071 |
+
<div class="dash-loading">
|
| 2072 |
+
<div class="loader-ring" style="width:42px;height:42px;border-radius:50%;border:1.5px solid rgba(40,80,140,0.15);border-top-color:var(--accent);animation:spin 0.8s linear infinite;"></div>
|
| 2073 |
+
<p>AI đang tính toán...</p>
|
| 2074 |
+
</div>
|
| 2075 |
+
`;
|
| 2076 |
+
|
| 2077 |
+
try {
|
| 2078 |
+
const horizon = horizonInput.value || 24;
|
| 2079 |
+
const fData = await apiRequest(`/api/forecast/${encodeURIComponent(symbol)}?interval=${interval}&horizon=${horizon}`);
|
| 2080 |
+
|
| 2081 |
+
if (currentSymbol !== symbol) return; // Discard if user switched
|
| 2082 |
+
|
| 2083 |
+
renderAnalysisPanel(symbol, interval, fData);
|
| 2084 |
+
|
| 2085 |
+
let isBull = false;
|
| 2086 |
+
let lastForecastVal = 0;
|
| 2087 |
+
|
| 2088 |
+
if (fData.error || !fData.forecast || fData.forecast.length === 0) {
|
| 2089 |
+
updateStatus('AI: ' + (fData.error || 'Thiếu dữ liệu dự báo'), 'warning');
|
| 2090 |
+
} else {
|
| 2091 |
+
// Safeguard: Ensure we have candle data before aligning
|
| 2092 |
+
if (!lastCandleData || !lastCandleData.time) {
|
| 2093 |
+
updateStatus(`${symbol} · ${interval} — AI Sẵn sàng`, 'ok');
|
| 2094 |
+
return;
|
| 2095 |
+
}
|
| 2096 |
+
|
| 2097 |
+
// Alignment Fix: Prepend the current price candle to the forecast data
|
| 2098 |
+
const currentPoint = { time: lastCandleData.time, value: lastCandleData.close };
|
| 2099 |
+
|
| 2100 |
+
const p50 = [currentPoint, ...fData.forecast.map(d => ({ time: d.time, value: d.p50 }))];
|
| 2101 |
+
const p10 = [currentPoint, ...fData.forecast.map(d => ({ time: d.time, value: d.p10 }))];
|
| 2102 |
+
const p90 = [currentPoint, ...fData.forecast.map(d => ({ time: d.time, value: d.p90 }))];
|
| 2103 |
+
|
| 2104 |
+
const first = p50[0]?.value ?? 0;
|
| 2105 |
+
lastForecastVal = p50[p50.length - 1]?.value ?? 0;
|
| 2106 |
+
isBull = lastForecastVal >= first;
|
| 2107 |
+
|
| 2108 |
+
const aiMain = isBull ? '#1dba8a' : '#e05560';
|
| 2109 |
+
const aiBand = isBull ? 'rgba(29,186,138,0.1)' : 'rgba(224,85,96,0.1)';
|
| 2110 |
+
|
| 2111 |
+
p50Series.applyOptions({ color: aiMain, visible: true });
|
| 2112 |
+
p10Series.applyOptions({ color: aiBand, visible: true });
|
| 2113 |
+
p90Series.applyOptions({ color: aiBand, visible: true });
|
| 2114 |
+
|
| 2115 |
+
p50Series.setData(p50);
|
| 2116 |
+
p10Series.setData(p10);
|
| 2117 |
+
p90Series.setData(p90);
|
| 2118 |
+
}
|
| 2119 |
+
|
| 2120 |
+
const trend = isBull ? '↑ TĂNG' : '↓ GIẢM';
|
| 2121 |
+
const confidence = fData.analysis?.confidence ?? fData.ensemble?.confidence ?? 0;
|
| 2122 |
+
if (currentSymbol !== symbol || timeframeSelect.value !== interval) return;
|
| 2123 |
+
|
| 2124 |
+
updateStatus(
|
| 2125 |
+
`${symbol} · ${interval} | AI: ${trend} (${Math.round(confidence)}%) | Giá: ${formatPrice(lastCandleData?.close)}`,
|
| 2126 |
+
'ok'
|
| 2127 |
+
);
|
| 2128 |
+
|
| 2129 |
+
} catch (e) {
|
| 2130 |
+
if (e.name === 'AbortError') return; // Silent abort
|
| 2131 |
+
console.error('Stage 2 Fetch Error:', e);
|
| 2132 |
+
panel.innerHTML = `<div class="dash-loading"><p style="color:#fb7185;">Không thể kết nối AI: ${e.message}</p></div>`;
|
| 2133 |
+
updateStatus('AI Offline', 'warning');
|
| 2134 |
+
}
|
| 2135 |
+
}
|
| 2136 |
+
|
| 2137 |
+
/* ── Event listeners ───────────────────────── */
|
| 2138 |
+
const reopenSidebarBtn = document.getElementById('reopenSidebarBtn');
|
| 2139 |
+
const toggleMarketBtn = document.getElementById('toggleMarketBtn');
|
| 2140 |
+
|
| 2141 |
+
function toggleSidebar(forceOpen = null) {
|
| 2142 |
+
const isCollapsed = sidebar.classList.contains('collapsed');
|
| 2143 |
+
const targetState = (forceOpen !== null) ? !forceOpen : !isCollapsed;
|
| 2144 |
+
|
| 2145 |
+
sidebar.classList.toggle('collapsed', targetState);
|
| 2146 |
+
reopenSidebarBtn.style.display = targetState ? 'flex' : 'none';
|
| 2147 |
+
|
| 2148 |
+
setTimeout(() => chart.applyOptions({ width: chartEl.clientWidth }), 300);
|
| 2149 |
+
}
|
| 2150 |
+
|
| 2151 |
+
// Unified toggle handlers
|
| 2152 |
+
toggleSidebarBtn.onclick = () => toggleSidebar(false); // Close
|
| 2153 |
+
reopenSidebarBtn.onclick = () => toggleSidebar(true); // Open
|
| 2154 |
+
toggleMarketBtn.onclick = () => toggleSidebar(); // Toggle
|
| 2155 |
+
|
| 2156 |
+
timeframeSelect.onchange = refreshChart;
|
| 2157 |
+
indicatorSelect.onchange = refreshChart;
|
| 2158 |
+
|
| 2159 |
+
// "Phân tích" button: toggle dashboard ON/OFF without reloading chart
|
| 2160 |
+
refreshBtn.onclick = () => {
|
| 2161 |
+
const isActive = analysisPanel.classList.contains('active');
|
| 2162 |
+
if (!isActive) {
|
| 2163 |
+
// Show dashboard — use cached data if available, or fetch fresh
|
| 2164 |
+
if (lastAnalysisPayload && lastAnalysisSymbol === currentSymbol) {
|
| 2165 |
+
renderAnalysisPanel(lastAnalysisSymbol, lastAnalysisInterval, lastAnalysisPayload);
|
| 2166 |
+
analysisPanel.classList.add('active');
|
| 2167 |
+
} else {
|
| 2168 |
+
// No cached data: fetch from API without reloading chart
|
| 2169 |
+
analysisPanel.classList.add('active');
|
| 2170 |
+
analysisPanel.innerHTML = `
|
| 2171 |
+
<div class="dash-loading">
|
| 2172 |
+
<div class="loader-ring" style="width:48px;height:48px;"></div>
|
| 2173 |
+
<p>Hệ thống AI đang khởi tạo dữ liệu...</p>
|
| 2174 |
+
</div>
|
| 2175 |
+
`;
|
| 2176 |
+
fetchAIAnalysis(currentSymbol, timeframeSelect.value);
|
| 2177 |
+
}
|
| 2178 |
+
} else {
|
| 2179 |
+
// Hide dashboard
|
| 2180 |
+
analysisPanel.classList.remove('active');
|
| 2181 |
+
}
|
| 2182 |
+
};
|
| 2183 |
+
|
| 2184 |
+
fitBtn.onclick = () => chart.timeScale().fitContent();
|
| 2185 |
+
|
| 2186 |
+
/* ── Click out to close search ─────────────── */
|
| 2187 |
+
document.addEventListener('click', (e) => {
|
| 2188 |
+
if (!symbolSearch.contains(e.target) && !searchResults.contains(e.target)) {
|
| 2189 |
+
searchResults.classList.remove('visible');
|
| 2190 |
+
}
|
| 2191 |
+
});
|
| 2192 |
+
|
| 2193 |
+
/* ── Resize handler ────────────────────────── */
|
| 2194 |
+
const ro = new ResizeObserver(() => {
|
| 2195 |
+
chart.applyOptions({
|
| 2196 |
+
width: chartEl.clientWidth,
|
| 2197 |
+
height: chartEl.clientHeight,
|
| 2198 |
+
});
|
| 2199 |
+
});
|
| 2200 |
+
ro.observe(chartEl);
|
| 2201 |
+
|
| 2202 |
+
/* ── Bootstrap ─────────────────────────────── */
|
| 2203 |
+
(async () => {
|
| 2204 |
+
await loadSymbols();
|
| 2205 |
+
await refreshMarketStatus();
|
| 2206 |
+
|
| 2207 |
+
// Start polling
|
| 2208 |
+
setInterval(refreshMarketStatus, 60000); // 1m
|
| 2209 |
+
setInterval(refreshOverview, 15000); // 15s
|
| 2210 |
+
|
| 2211 |
+
// Default start
|
| 2212 |
+
await switchSymbol('XAUUSD');
|
| 2213 |
+
})();
|
| 2214 |
+
</script>
|
| 2215 |
+
</body>
|
| 2216 |
+
|
| 2217 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.136.0
|
| 2 |
+
uvicorn[standard]==0.44.0
|
| 3 |
+
httpx==0.28.1
|
| 4 |
+
pandas==2.2.2
|
| 5 |
+
numpy==2.4.4
|
| 6 |
+
yfinance==1.3.0
|
| 7 |
+
ccxt==4.5.49
|
| 8 |
+
torch==2.11.0
|
| 9 |
+
aiofiles==25.1.0
|
| 10 |
+
pytz==2026.1.post1
|
| 11 |
+
einops==0.8.1
|
| 12 |
+
huggingface_hub==0.33.1
|
| 13 |
+
matplotlib==3.9.3
|
| 14 |
+
tqdm==4.67.1
|
| 15 |
+
safetensors==0.6.2
|
run.bat
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
setlocal EnableExtensions EnableDelayedExpansion
|
| 3 |
+
cd /d "%~dp0"
|
| 4 |
+
|
| 5 |
+
title Kronos AI Analysis
|
| 6 |
+
|
| 7 |
+
set "PYTHONUTF8=1"
|
| 8 |
+
set "PYTHONIOENCODING=utf-8"
|
| 9 |
+
set "KRONOS_PRELOAD=1"
|
| 10 |
+
set "VENV_DIR=%CD%\venv"
|
| 11 |
+
set "VENV_PY=%VENV_DIR%\Scripts\python.exe"
|
| 12 |
+
|
| 13 |
+
echo ==========================================
|
| 14 |
+
echo KRONOS AI ANALYSIS - STARTUP
|
| 15 |
+
echo ==========================================
|
| 16 |
+
echo.
|
| 17 |
+
|
| 18 |
+
echo [1/5] Checking Python 3.11...
|
| 19 |
+
py -3.11 -V >nul 2>&1
|
| 20 |
+
if errorlevel 1 (
|
| 21 |
+
echo [ERROR] Python 3.11 is required but was not found.
|
| 22 |
+
echo [HINT] Install Python 3.11 and run this script again.
|
| 23 |
+
goto :end
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
echo [2/5] Checking virtual environment...
|
| 27 |
+
if exist "%VENV_PY%" (
|
| 28 |
+
"%VENV_PY%" -V >nul 2>&1
|
| 29 |
+
if errorlevel 1 (
|
| 30 |
+
echo [WARN] Existing venv is invalid. Rebuilding a clean one...
|
| 31 |
+
if exist "%CD%\venv_legacy_broken" rmdir /s /q "%CD%\venv_legacy_broken"
|
| 32 |
+
ren "%VENV_DIR%" "venv_legacy_broken"
|
| 33 |
+
)
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
if not exist "%VENV_PY%" (
|
| 37 |
+
echo [INFO] Creating fresh Python 3.11 virtual environment...
|
| 38 |
+
py -3.11 -m venv "%VENV_DIR%"
|
| 39 |
+
if errorlevel 1 (
|
| 40 |
+
echo [ERROR] Failed to create virtual environment.
|
| 41 |
+
goto :end
|
| 42 |
+
)
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
set "REQUESTS_CA_BUNDLE=%VENV_DIR%\Lib\site-packages\pip\_vendor\certifi\cacert.pem"
|
| 46 |
+
|
| 47 |
+
echo [3/5] Installing or verifying dependencies...
|
| 48 |
+
"%VENV_PY%" -m pip install -r requirements.txt
|
| 49 |
+
if errorlevel 1 (
|
| 50 |
+
echo [ERROR] Dependency installation failed.
|
| 51 |
+
goto :end
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
echo [4/5] Running Kronos runtime self-check...
|
| 55 |
+
"%VENV_PY%" -c "import backend.main as m; assert m.KRONOS_AVAILABLE, 'Kronos import failed'; print('KRONOS_PATH=', m.KRONOS_PATH); print('SYMBOLS=', len(m.SYMBOLS))"
|
| 56 |
+
if errorlevel 1 (
|
| 57 |
+
echo [ERROR] Backend self-check failed.
|
| 58 |
+
goto :end
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
echo [5/5] Starting backend...
|
| 62 |
+
echo [INFO] Opening http://127.0.0.1:8000
|
| 63 |
+
start "" http://127.0.0.1:8000
|
| 64 |
+
echo.
|
| 65 |
+
echo ------------------------------------------
|
| 66 |
+
echo KEEP THIS WINDOW OPEN WHILE USING THE APP
|
| 67 |
+
echo TO STOP: PRESS CTRL+C
|
| 68 |
+
echo ------------------------------------------
|
| 69 |
+
echo.
|
| 70 |
+
"%VENV_PY%" -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload
|
| 71 |
+
|
| 72 |
+
:end
|
| 73 |
+
pause
|