Commit inicial: Projeto YuClusters
This commit is contained in:
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Logs and databases
|
||||||
|
*.log
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# YuClusters Local 📊
|
||||||
|
|
||||||
|
YuClusters Local is a professional footprint chart and volumetric cluster analyzer integrated directly with your local **MetaTrader 5 (MT5)** terminal.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Real-Time Footprint Charting**: Ticks polled at ~100ms intervals and visualised dynamically using an optimized HTML5 Canvas element.
|
||||||
|
- **Diagonal Imbalances Detection**: Identifies aggressive buying or selling imbalances ($R \ge 3.0$).
|
||||||
|
- **Stacked Imbalances Highlights**: Dynamically spots 3+ consecutive imbalances in the same direction.
|
||||||
|
- **POC Highlight**: Identifies and highlights the highest volume nodes within each cluster using a gold border.
|
||||||
|
- **Interactive Drag & Pan UI**: Fully-featured HTML5 Canvas dashboard with drag-to-scroll horizontal/vertical panning and zoom adjustment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### 1. Prerequisites
|
||||||
|
- **Windows OS** (required by MetaTrader 5 API)
|
||||||
|
- **MetaTrader 5 Terminal** running locally and logged into your broker account
|
||||||
|
- **Python 3.11+** installed and added to PATH
|
||||||
|
- **Node.js 18+** installed
|
||||||
|
|
||||||
|
### 2. Backend Installation
|
||||||
|
|
||||||
|
1. From the project root, install Python dependencies:
|
||||||
|
```powershell
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
2. Set your environment configurations in `config/settings.py` or export them as environment variables (e.g. `MT5_LOGIN`, `MT5_PASSWORD`, `MT5_SERVER`).
|
||||||
|
|
||||||
|
### 3. Frontend Installation
|
||||||
|
|
||||||
|
1. Navigate to the `frontend/` folder:
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
2. Install npm packages:
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Guide
|
||||||
|
|
||||||
|
### 1. Run the Backend Server
|
||||||
|
Start the FastAPI server (this will automatically launch the MetaTrader 5 polling collector):
|
||||||
|
```powershell
|
||||||
|
python backend/server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run the React Frontend
|
||||||
|
Start the Vite development server:
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) in your web browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Calculations (from SKILL.md)
|
||||||
|
|
||||||
|
1. **Diagonal Buy Imbalance**: `ask_vol[i] >= R * bid_vol[i-1]` (compares with price level below)
|
||||||
|
2. **Diagonal Sell Imbalance**: `bid_vol[i] >= R * ask_vol[i+1]` (compares with price level above)
|
||||||
|
3. **POC (Point of Control)**: Price level with the highest total volume (`ask_vol + bid_vol`) inside a cluster. If volumes tie, the highest price wins.
|
||||||
|
4. **Stacked Imbalance Zone**: A vertical zone spanning 3 or more consecutive levels with the same imbalance direction.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# SKILL.md — YuClusters Local
|
||||||
|
|
||||||
|
## Regras de Cálculo (NÃO ALTERAR)
|
||||||
|
|
||||||
|
1. Imbalance diagonal BUY no nível i: ask_vol[i] >= R * bid_vol[i-1] (compara com nível ABAIXO)
|
||||||
|
2. Imbalance diagonal SELL no nível i: bid_vol[i] >= R * ask_vol[i+1] (compara com nível ACIMA)
|
||||||
|
3. POC = nível com MAIOR (ask_vol + bid_vol) dentro do cluster
|
||||||
|
4. Delta do cluster = soma de todos os delta[i] de todos os níveis
|
||||||
|
5. Stacked = mínimo de STACKED_MIN_COUNT níveis consecutivos com imbalance na MESMA direção
|
||||||
|
|
||||||
|
## Regras de Implementação
|
||||||
|
|
||||||
|
- NUNCA misturar lógica de agregação com lógica de UI
|
||||||
|
- NUNCA recalcular clusters históricos fechados
|
||||||
|
- SEMPRE usar tick_size do MT5 como granularidade mínima de nível de preço
|
||||||
|
- Conexão MT5 SEMPRE via lib oficial `MetaTrader5`, nunca via subprocess ou API REST externa
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
class FootprintCluster:
|
||||||
|
def __init__(self, tick_size: float = 1.0):
|
||||||
|
self.tick_size = tick_size
|
||||||
|
self.cluster_id = str(uuid.uuid4())
|
||||||
|
self.status = "active"
|
||||||
|
self.open_time: Optional[int] = None
|
||||||
|
self.close_reason: Optional[str] = None
|
||||||
|
self.open_price: Optional[float] = None
|
||||||
|
self.close_price: Optional[float] = None
|
||||||
|
self.high: Optional[float] = None
|
||||||
|
self.low: Optional[float] = None
|
||||||
|
self.poc: Optional[float] = None
|
||||||
|
self.total_delta: float = 0.0
|
||||||
|
self.total_volume: float = 0.0
|
||||||
|
|
||||||
|
# levels: price_float -> { 'ask': float, 'bid': float }
|
||||||
|
# internally we keep float keys to make sorting and arithmetic easy
|
||||||
|
self.levels: Dict[float, Dict[str, float]] = {}
|
||||||
|
self.stacked: Dict[str, Any] = {
|
||||||
|
"buy": False,
|
||||||
|
"sell": False,
|
||||||
|
"price_range": []
|
||||||
|
}
|
||||||
|
self.advanced_metrics: Dict[str, Any] = {
|
||||||
|
"poc_position": None,
|
||||||
|
"pattern": None,
|
||||||
|
"top_extreme": None,
|
||||||
|
"bottom_extreme": None
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_tick(self, price: float, volume: float, is_buy: bool, timestamp_msc: int) -> None:
|
||||||
|
if self.open_time is None:
|
||||||
|
self.open_time = timestamp_msc
|
||||||
|
self.open_price = price
|
||||||
|
|
||||||
|
self.close_price = price
|
||||||
|
|
||||||
|
# Round price to the nearest tick_size to avoid float precision issues
|
||||||
|
rounded_price = round(price / self.tick_size) * self.tick_size
|
||||||
|
|
||||||
|
if rounded_price not in self.levels:
|
||||||
|
self.levels[rounded_price] = {"ask": 0.0, "bid": 0.0}
|
||||||
|
|
||||||
|
if is_buy:
|
||||||
|
self.levels[rounded_price]["ask"] += volume
|
||||||
|
else:
|
||||||
|
self.levels[rounded_price]["bid"] += volume
|
||||||
|
|
||||||
|
# Update High/Low
|
||||||
|
if self.high is None or rounded_price > self.high:
|
||||||
|
self.high = rounded_price
|
||||||
|
if self.low is None or rounded_price < self.low:
|
||||||
|
self.low = rounded_price
|
||||||
|
|
||||||
|
# Recalculate totals, POC, imbalances and stacked imbalances
|
||||||
|
self._recalculate()
|
||||||
|
|
||||||
|
def _recalculate(self) -> None:
|
||||||
|
if not self.levels:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.total_volume = 0.0
|
||||||
|
self.total_delta = 0.0
|
||||||
|
|
||||||
|
# First pass: calc totals and delta per level
|
||||||
|
for price, data in self.levels.items():
|
||||||
|
ask = data["ask"]
|
||||||
|
bid = data["bid"]
|
||||||
|
lvl_delta = ask - bid
|
||||||
|
lvl_total = ask + bid
|
||||||
|
self.total_volume += lvl_total
|
||||||
|
self.total_delta += lvl_delta
|
||||||
|
|
||||||
|
# Find POC: level with the highest total volume.
|
||||||
|
# Tie-breaker: choose the highest price level.
|
||||||
|
sorted_prices = sorted(self.levels.keys())
|
||||||
|
best_price = sorted_prices[0]
|
||||||
|
max_total = -1.0
|
||||||
|
for price in sorted_prices:
|
||||||
|
total_vol = self.levels[price]["ask"] + self.levels[price]["bid"]
|
||||||
|
if total_vol > max_total:
|
||||||
|
max_total = total_vol
|
||||||
|
best_price = price
|
||||||
|
elif total_vol == max_total:
|
||||||
|
if price > best_price:
|
||||||
|
best_price = price
|
||||||
|
self.poc = best_price
|
||||||
|
|
||||||
|
# Second pass: calculate imbalances diagonal and stacked imbalances
|
||||||
|
# To avoid division by zero:
|
||||||
|
# imbalance_buy[i] is True if ask_vol[i] >= R * bid_vol[i - tick_size]
|
||||||
|
# imbalance_sell[i] is True if bid_vol[i] >= R * ask_vol[i + tick_size]
|
||||||
|
R = settings.IMBALANCE_RATIO
|
||||||
|
|
||||||
|
imbalances_buy = {}
|
||||||
|
imbalances_sell = {}
|
||||||
|
|
||||||
|
for price in sorted_prices:
|
||||||
|
ask_val = self.levels[price]["ask"]
|
||||||
|
bid_val = self.levels[price]["bid"]
|
||||||
|
|
||||||
|
# Lower level price
|
||||||
|
lower_price = round((price - self.tick_size) / self.tick_size) * self.tick_size
|
||||||
|
bid_below = self.levels[lower_price]["bid"] if lower_price in self.levels else 0.0
|
||||||
|
|
||||||
|
# Upper level price
|
||||||
|
upper_price = round((price + self.tick_size) / self.tick_size) * self.tick_size
|
||||||
|
ask_above = self.levels[upper_price]["ask"] if upper_price in self.levels else 0.0
|
||||||
|
|
||||||
|
# Buy Imbalance (diagonal): ask vs bid_below
|
||||||
|
# Avoid triggering on 0 vs 0
|
||||||
|
if ask_val > 0 and ask_val >= R * bid_below:
|
||||||
|
imbalances_buy[price] = True
|
||||||
|
else:
|
||||||
|
imbalances_buy[price] = False
|
||||||
|
|
||||||
|
# Sell Imbalance (diagonal): bid vs ask_above
|
||||||
|
if bid_val > 0 and bid_val >= R * ask_above:
|
||||||
|
imbalances_sell[price] = True
|
||||||
|
else:
|
||||||
|
imbalances_sell[price] = False
|
||||||
|
|
||||||
|
# Detect stacked imbalances: 3+ consecutive levels with imbalance in the same direction
|
||||||
|
# Let's check contiguous price levels in steps of tick_size
|
||||||
|
stacked_buy = False
|
||||||
|
stacked_sell = False
|
||||||
|
stacked_buy_prices = []
|
||||||
|
stacked_sell_prices = []
|
||||||
|
|
||||||
|
min_consecutive = settings.STACKED_MIN_COUNT
|
||||||
|
|
||||||
|
# We need to check all possible price steps from low to high
|
||||||
|
if self.low is not None and self.high is not None:
|
||||||
|
current_price = self.low
|
||||||
|
consec_buy = []
|
||||||
|
consec_sell = []
|
||||||
|
|
||||||
|
while current_price <= self.high:
|
||||||
|
rounded_p = round(current_price / self.tick_size) * self.tick_size
|
||||||
|
|
||||||
|
# Check Buy
|
||||||
|
if imbalances_buy.get(rounded_p, False):
|
||||||
|
consec_buy.append(rounded_p)
|
||||||
|
else:
|
||||||
|
if len(consec_buy) >= min_consecutive:
|
||||||
|
stacked_buy = True
|
||||||
|
stacked_buy_prices.extend(consec_buy)
|
||||||
|
consec_buy = []
|
||||||
|
|
||||||
|
# Check Sell
|
||||||
|
if imbalances_sell.get(rounded_p, False):
|
||||||
|
consec_sell.append(rounded_p)
|
||||||
|
else:
|
||||||
|
if len(consec_sell) >= min_consecutive:
|
||||||
|
stacked_sell = True
|
||||||
|
stacked_sell_prices.extend(consec_sell)
|
||||||
|
consec_sell = []
|
||||||
|
|
||||||
|
current_price += self.tick_size
|
||||||
|
|
||||||
|
# Final check at the end of the loop
|
||||||
|
if len(consec_buy) >= min_consecutive:
|
||||||
|
stacked_buy = True
|
||||||
|
stacked_buy_prices.extend(consec_buy)
|
||||||
|
if len(consec_sell) >= min_consecutive:
|
||||||
|
stacked_sell = True
|
||||||
|
stacked_sell_prices.extend(consec_sell)
|
||||||
|
|
||||||
|
# Build output properties for stacked
|
||||||
|
self.stacked = {
|
||||||
|
"buy": stacked_buy,
|
||||||
|
"sell": stacked_sell,
|
||||||
|
"price_range": sorted(list(set(stacked_buy_prices + stacked_sell_prices)))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store imbalances back in levels for JSON output
|
||||||
|
for price in sorted_prices:
|
||||||
|
imb = None
|
||||||
|
if imbalances_buy.get(price, False) and imbalances_sell.get(price, False):
|
||||||
|
imb = "both"
|
||||||
|
elif imbalances_buy.get(price, False):
|
||||||
|
imb = "buy"
|
||||||
|
elif imbalances_sell.get(price, False):
|
||||||
|
imb = "sell"
|
||||||
|
|
||||||
|
self.levels[price]["delta"] = self.levels[price]["ask"] - self.levels[price]["bid"]
|
||||||
|
self.levels[price]["total"] = self.levels[price]["ask"] + self.levels[price]["bid"]
|
||||||
|
self.levels[price]["imbalance"] = imb
|
||||||
|
|
||||||
|
# Advanced Metrics Calculation
|
||||||
|
if self.high is not None and self.low is not None and self.high > self.low and self.total_volume > 0:
|
||||||
|
# 1. POC Position
|
||||||
|
poc_percent = (self.poc - self.low) / (self.high - self.low)
|
||||||
|
if poc_percent >= 0.65:
|
||||||
|
self.advanced_metrics["poc_position"] = "top"
|
||||||
|
elif poc_percent <= 0.35:
|
||||||
|
self.advanced_metrics["poc_position"] = "bottom"
|
||||||
|
else:
|
||||||
|
self.advanced_metrics["poc_position"] = "middle"
|
||||||
|
|
||||||
|
# 2. P and B Patterns
|
||||||
|
mid_price = (self.high + self.low) / 2.0
|
||||||
|
vol_above = sum(self.levels[p]["total"] for p in sorted_prices if p >= mid_price)
|
||||||
|
vol_below = sum(self.levels[p]["total"] for p in sorted_prices if p < mid_price)
|
||||||
|
|
||||||
|
if vol_above / self.total_volume > 0.65:
|
||||||
|
self.advanced_metrics["pattern"] = "P"
|
||||||
|
elif vol_below / self.total_volume > 0.65:
|
||||||
|
self.advanced_metrics["pattern"] = "B"
|
||||||
|
else:
|
||||||
|
self.advanced_metrics["pattern"] = "normal"
|
||||||
|
|
||||||
|
# 3. Extremes (Exhaustion / Absorption)
|
||||||
|
top_vol = self.levels[self.high]["total"]
|
||||||
|
next_top_price = round((self.high - self.tick_size) / self.tick_size) * self.tick_size
|
||||||
|
next_top_vol = self.levels.get(next_top_price, {}).get("total", 0.0)
|
||||||
|
avg_vol = self.total_volume / len(self.levels)
|
||||||
|
|
||||||
|
if top_vol < avg_vol * 0.2 and next_top_vol > avg_vol * 0.5:
|
||||||
|
self.advanced_metrics["top_extreme"] = "exhaustion"
|
||||||
|
elif top_vol > avg_vol * 2.5:
|
||||||
|
self.advanced_metrics["top_extreme"] = "absorption"
|
||||||
|
else:
|
||||||
|
self.advanced_metrics["top_extreme"] = "normal"
|
||||||
|
|
||||||
|
bottom_vol = self.levels[self.low]["total"]
|
||||||
|
next_bot_price = round((self.low + self.tick_size) / self.tick_size) * self.tick_size
|
||||||
|
next_bot_vol = self.levels.get(next_bot_price, {}).get("total", 0.0)
|
||||||
|
|
||||||
|
if bottom_vol < avg_vol * 0.2 and next_bot_vol > avg_vol * 0.5:
|
||||||
|
self.advanced_metrics["bottom_extreme"] = "exhaustion"
|
||||||
|
elif bottom_vol > avg_vol * 2.5:
|
||||||
|
self.advanced_metrics["bottom_extreme"] = "absorption"
|
||||||
|
else:
|
||||||
|
self.advanced_metrics["bottom_extreme"] = "normal"
|
||||||
|
|
||||||
|
# 4. Ratios (Exhaustion/Absorption quantification at extremes)
|
||||||
|
if next_top_vol > 0:
|
||||||
|
self.advanced_metrics["high_ratio"] = round(top_vol / next_top_vol, 2)
|
||||||
|
else:
|
||||||
|
self.advanced_metrics["high_ratio"] = round(top_vol, 2) if top_vol > 0 else 0.0
|
||||||
|
|
||||||
|
if next_bot_vol > 0:
|
||||||
|
self.advanced_metrics["low_ratio"] = round(bottom_vol / next_bot_vol, 2)
|
||||||
|
else:
|
||||||
|
self.advanced_metrics["low_ratio"] = round(bottom_vol, 2) if bottom_vol > 0 else 0.0
|
||||||
|
|
||||||
|
# 5. Delta Divergence (Price direction vs Order Flow Delta)
|
||||||
|
self.advanced_metrics["delta_divergence"] = False
|
||||||
|
if self.open_price is not None and self.close_price is not None:
|
||||||
|
is_bull = self.close_price > self.open_price
|
||||||
|
is_bear = self.close_price < self.open_price
|
||||||
|
if (is_bull and self.total_delta < 0) or (is_bear and self.total_delta > 0):
|
||||||
|
self.advanced_metrics["delta_divergence"] = True
|
||||||
|
|
||||||
|
def should_close(self, current_time_msc: int) -> Optional[str]:
|
||||||
|
# Check range
|
||||||
|
if self.high is not None and self.low is not None:
|
||||||
|
points = self.high - self.low
|
||||||
|
# If tick_size is 1.0, range is in points. Let's compare directly.
|
||||||
|
# (or points/tick_size >= CLUSTER_RANGE_POINTS)
|
||||||
|
# Standard MT5 points. If range in points exceeds setting:
|
||||||
|
if points >= settings.CLUSTER_RANGE_POINTS:
|
||||||
|
return "range"
|
||||||
|
|
||||||
|
# Check volume
|
||||||
|
if self.total_volume >= settings.CLUSTER_VOLUME_MAX:
|
||||||
|
return "volume"
|
||||||
|
|
||||||
|
# Check delta
|
||||||
|
if abs(self.total_delta) >= settings.CLUSTER_DELTA_MAX:
|
||||||
|
return "delta"
|
||||||
|
|
||||||
|
# Check time
|
||||||
|
if self.open_time is not None:
|
||||||
|
elapsed = (current_time_msc - self.open_time) / 1000.0
|
||||||
|
if elapsed >= settings.CLUSTER_TIME_SECONDS:
|
||||||
|
return "time"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self, reason: str) -> None:
|
||||||
|
self.status = "closed"
|
||||||
|
self.close_reason = reason
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
# Convert keys in levels to string for JSON compatibility
|
||||||
|
levels_str = {}
|
||||||
|
for price, data in sorted(self.levels.items(), reverse=True):
|
||||||
|
levels_str[f"{price:.5f}"] = {
|
||||||
|
"ask": float(data.get("ask", 0.0)),
|
||||||
|
"bid": float(data.get("bid", 0.0)),
|
||||||
|
"delta": float(data.get("delta", 0.0)),
|
||||||
|
"total": float(data.get("total", 0.0)),
|
||||||
|
"imbalance": data.get("imbalance", None)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cluster_id": self.cluster_id,
|
||||||
|
"tick_size": self.tick_size,
|
||||||
|
"status": self.status,
|
||||||
|
"open_time": int(self.open_time) if self.open_time is not None else None,
|
||||||
|
"close_reason": self.close_reason,
|
||||||
|
"open_price": float(self.open_price) if self.open_price is not None else None,
|
||||||
|
"close_price": float(self.close_price) if self.close_price is not None else None,
|
||||||
|
"high": float(self.high) if self.high is not None else None,
|
||||||
|
"low": float(self.low) if self.low is not None else None,
|
||||||
|
"poc": float(self.poc) if self.poc is not None else None,
|
||||||
|
"total_delta": float(self.total_delta),
|
||||||
|
"total_volume": float(self.total_volume),
|
||||||
|
"levels": levels_str,
|
||||||
|
"stacked": {
|
||||||
|
"buy": bool(self.stacked.get("buy", False)),
|
||||||
|
"sell": bool(self.stacked.get("sell", False)),
|
||||||
|
"price_range": [float(p) for p in self.stacked.get("price_range", [])]
|
||||||
|
},
|
||||||
|
"advanced_metrics": self.advanced_metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Aggregator:
|
||||||
|
def __init__(self, tick_size: float = 1.0):
|
||||||
|
self.tick_size = tick_size
|
||||||
|
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
|
||||||
|
self.history: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def process_tick(self, price: float, volume: float, is_buy: bool, timestamp_msc: int) -> tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Process a single tick.
|
||||||
|
Returns:
|
||||||
|
(active_cluster_json, closed_cluster_json_if_just_closed)
|
||||||
|
"""
|
||||||
|
self.active_cluster.add_tick(price, volume, is_buy, timestamp_msc)
|
||||||
|
|
||||||
|
# Check if the active cluster should be closed
|
||||||
|
close_reason = self.active_cluster.should_close(timestamp_msc)
|
||||||
|
|
||||||
|
closed_json = None
|
||||||
|
if close_reason:
|
||||||
|
self.active_cluster.close(close_reason)
|
||||||
|
closed_json = self.active_cluster.to_json()
|
||||||
|
|
||||||
|
# Save to history
|
||||||
|
self.history.append(closed_json)
|
||||||
|
if len(self.history) > settings.HISTORY_BUFFER_SIZE:
|
||||||
|
self.history.pop(0)
|
||||||
|
|
||||||
|
# Start new cluster
|
||||||
|
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
|
||||||
|
# The next tick will set the open_time of the new cluster.
|
||||||
|
|
||||||
|
return self.active_cluster.to_json(), closed_json
|
||||||
|
|
||||||
|
|
||||||
|
def classify_tick(last: float, bid: float, ask: float, flags: int) -> bool:
|
||||||
|
"""
|
||||||
|
Classify tick aggressiveness.
|
||||||
|
TICK_FLAG_BUY = 32 -> BUY (True)
|
||||||
|
TICK_FLAG_SELL = 64 -> SELL (False)
|
||||||
|
TICK_FLAG_ASK = 4 -> ASK (True)
|
||||||
|
TICK_FLAG_BID = 2 -> BID (False)
|
||||||
|
"""
|
||||||
|
if flags & 32:
|
||||||
|
return True
|
||||||
|
elif flags & 64:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if last > 0:
|
||||||
|
if last >= ask:
|
||||||
|
return True
|
||||||
|
elif last <= bid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Forex quote ticks fallback
|
||||||
|
if flags & 4:
|
||||||
|
return True
|
||||||
|
elif flags & 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Standard fallback if between spread: closer to ask is BUY
|
||||||
|
if ask > bid and last > 0:
|
||||||
|
return (last - bid) >= (ask - last)
|
||||||
|
return True
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from typing import Callable, Optional, Any
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
import MetaTrader5 as mt5
|
||||||
|
from config import settings
|
||||||
|
from backend.aggregator import Aggregator, classify_tick
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
logger = logging.getLogger("mt5_collector")
|
||||||
|
|
||||||
|
class MT5Collector:
|
||||||
|
def __init__(self, aggregator: Aggregator, on_update_callback: Callable[[dict, Optional[dict]], Any]):
|
||||||
|
self.aggregator = aggregator
|
||||||
|
self.on_update_callback = on_update_callback
|
||||||
|
self.symbol = settings.MT5_SYMBOL
|
||||||
|
self.running = False
|
||||||
|
self.connected = False
|
||||||
|
self.last_tick_time_msc = 0
|
||||||
|
self.seen_ticks_buffer = set()
|
||||||
|
|
||||||
|
async def connect_mt5(self) -> bool:
|
||||||
|
"""
|
||||||
|
Attempts to initialize and login to the MetaTrader 5 terminal.
|
||||||
|
All MT5 calls are blocking, so they run in a thread executor.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mt5_path = r"C:\Program Files\MetaTrader 5\terminal64.exe"
|
||||||
|
initialized = await asyncio.to_thread(mt5.initialize, path=mt5_path)
|
||||||
|
if not initialized:
|
||||||
|
err = await asyncio.to_thread(mt5.last_error)
|
||||||
|
logger.error(f"MT5 initialize failed: {err}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if settings.MT5_LOGIN > 0:
|
||||||
|
login_success = await asyncio.to_thread(
|
||||||
|
mt5.login,
|
||||||
|
settings.MT5_LOGIN,
|
||||||
|
password=settings.MT5_PASSWORD,
|
||||||
|
server=settings.MT5_SERVER,
|
||||||
|
)
|
||||||
|
if not login_success:
|
||||||
|
err = await asyncio.to_thread(mt5.last_error)
|
||||||
|
logger.error(f"MT5 login failed: {err}")
|
||||||
|
await asyncio.to_thread(mt5.shutdown)
|
||||||
|
return False
|
||||||
|
|
||||||
|
symbol_info = await asyncio.to_thread(mt5.symbol_info, self.symbol)
|
||||||
|
if symbol_info is None:
|
||||||
|
logger.error(f"Symbol {self.symbol} not found.")
|
||||||
|
await asyncio.to_thread(mt5.shutdown)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not symbol_info.visible:
|
||||||
|
selected = await asyncio.to_thread(mt5.symbol_select, self.symbol, True)
|
||||||
|
if not selected:
|
||||||
|
logger.error(f"Failed to select/make visible symbol {self.symbol}.")
|
||||||
|
await asyncio.to_thread(mt5.shutdown)
|
||||||
|
return False
|
||||||
|
|
||||||
|
tick_size = symbol_info.trade_tick_size
|
||||||
|
if tick_size > 0:
|
||||||
|
self.aggregator.tick_size = tick_size
|
||||||
|
self.aggregator.active_cluster.tick_size = tick_size
|
||||||
|
logger.info(f"Set aggregator tick size to {tick_size}")
|
||||||
|
|
||||||
|
logger.info("Successfully connected to MetaTrader 5 and logged in.")
|
||||||
|
self.connected = True
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Exception during MT5 connection: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def disconnect_mt5(self):
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(mt5.shutdown)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during MT5 shutdown: {e}")
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
self.running = True
|
||||||
|
backoff = 1.0
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
if not self.connected:
|
||||||
|
success = await self.connect_mt5()
|
||||||
|
if not success:
|
||||||
|
logger.info(f"Reconnecting to MT5 in {backoff:.1f}s...")
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
backoff = min(backoff * 2, 60.0)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
backoff = 1.0
|
||||||
|
|
||||||
|
# Fetch from 6 hours ago so the chart isn't empty when started
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
start_time_dt = datetime.now() - timedelta(hours=6)
|
||||||
|
ticks = await asyncio.to_thread(
|
||||||
|
mt5.copy_ticks_from, self.symbol, start_time_dt, 100000, mt5.COPY_TICKS_ALL
|
||||||
|
)
|
||||||
|
if ticks is not None and len(ticks) > 0:
|
||||||
|
self.last_tick_time_msc = ticks[0]['time_msc']
|
||||||
|
else:
|
||||||
|
self.last_tick_time_msc = int(start_time_dt.timestamp() * 1000)
|
||||||
|
|
||||||
|
# Polling loop
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
polling_dt = datetime.fromtimestamp(self.last_tick_time_msc / 1000.0)
|
||||||
|
ticks = await asyncio.to_thread(
|
||||||
|
mt5.copy_ticks_from,
|
||||||
|
self.symbol,
|
||||||
|
polling_dt,
|
||||||
|
1000,
|
||||||
|
mt5.COPY_TICKS_ALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ticks is None:
|
||||||
|
err = await asyncio.to_thread(mt5.last_error)
|
||||||
|
logger.error(f"MT5 copy_ticks_from returned None: {err}")
|
||||||
|
self.connected = False
|
||||||
|
await self.disconnect_mt5()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(ticks) > 0:
|
||||||
|
logger.info(f"Fetched {len(ticks)} ticks starting at {ticks[0]['time_msc']}")
|
||||||
|
for tick in ticks:
|
||||||
|
msc = tick['time_msc']
|
||||||
|
|
||||||
|
if msc < self.last_tick_time_msc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tick_id = (msc, tick['bid'], tick['ask'], tick['last'], tick['volume_real'], tick['flags'])
|
||||||
|
if msc == self.last_tick_time_msc and tick_id in self.seen_ticks_buffer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msc > self.last_tick_time_msc:
|
||||||
|
self.seen_ticks_buffer.clear()
|
||||||
|
self.last_tick_time_msc = msc
|
||||||
|
|
||||||
|
self.seen_ticks_buffer.add(tick_id)
|
||||||
|
|
||||||
|
price = tick['last'] if tick['last'] > 0 else (tick['bid'] if tick['bid'] > 0 else tick['ask'])
|
||||||
|
volume = tick['volume_real'] if tick['volume_real'] > 0 else float(tick['volume'])
|
||||||
|
flags = tick['flags']
|
||||||
|
|
||||||
|
# Forex ticks often have volume=0 (they are just quote updates).
|
||||||
|
# We count each quote update as 1 unit of tick volume to build the footprint.
|
||||||
|
if volume == 0:
|
||||||
|
volume = 1.0
|
||||||
|
|
||||||
|
is_buy = classify_tick(price, tick['bid'], tick['ask'], flags)
|
||||||
|
|
||||||
|
active_json, closed_json = self.aggregator.process_tick(price, volume, is_buy, msc)
|
||||||
|
|
||||||
|
self.on_update_callback(active_json, closed_json)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during tick polling loop: {e}")
|
||||||
|
self.connected = False
|
||||||
|
await self.disconnect_mt5()
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
self.running = False
|
||||||
|
await self.disconnect_mt5()
|
||||||
|
logger.info("MT5 Collector stopped.")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from typing import Set, Dict, Any, Optional
|
||||||
|
|
||||||
|
# Add project root to sys.path
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from backend.aggregator import Aggregator
|
||||||
|
from backend.mt5_collector import MT5Collector
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
logger = logging.getLogger("server")
|
||||||
|
|
||||||
|
# Shared state
|
||||||
|
aggregator = Aggregator(tick_size=1.0)
|
||||||
|
active_connections: Set[WebSocket] = set()
|
||||||
|
collector_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
def broadcast_update(active_json: dict, closed_json: Optional[dict]):
|
||||||
|
"""
|
||||||
|
Callback executed by MT5Collector when a new tick is processed.
|
||||||
|
Schedules WebSocket sends on the event loop.
|
||||||
|
"""
|
||||||
|
message = {
|
||||||
|
"type": "tick",
|
||||||
|
"active": active_json,
|
||||||
|
"closed": closed_json
|
||||||
|
}
|
||||||
|
for connection in list(active_connections):
|
||||||
|
try:
|
||||||
|
asyncio.create_task(connection.send_json(message))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending update to client: {e}")
|
||||||
|
|
||||||
|
# Initialize MT5 Collector
|
||||||
|
collector = MT5Collector(aggregator, on_update_callback=broadcast_update)
|
||||||
|
|
||||||
|
async def _start_collector_delayed():
|
||||||
|
"""Wait a moment for the server to fully start, then begin polling MT5."""
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
logger.info("Starting MT5 collector background task...")
|
||||||
|
await collector.start()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global collector_task
|
||||||
|
# Fire-and-forget: start collector AFTER yielding so uvicorn is ready
|
||||||
|
collector_task = asyncio.create_task(_start_collector_delayed())
|
||||||
|
logger.info("Server is starting up...")
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Stopping MT5 collector task...")
|
||||||
|
collector.running = False
|
||||||
|
collector_task.cancel()
|
||||||
|
try:
|
||||||
|
await collector_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await collector.disconnect_mt5()
|
||||||
|
|
||||||
|
app = FastAPI(title="YuClusters Local Server", lifespan=lifespan)
|
||||||
|
|
||||||
|
# Add CORS Middleware so local frontend can query history
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/history")
|
||||||
|
async def get_history():
|
||||||
|
"""Returns the buffer of historical closed clusters."""
|
||||||
|
return aggregator.history
|
||||||
|
|
||||||
|
@app.websocket("/ws")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
active_connections.add(websocket)
|
||||||
|
logger.info(f"Client connected. Active connections: {len(active_connections)}")
|
||||||
|
|
||||||
|
# Send the current active cluster state on connection
|
||||||
|
try:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "init",
|
||||||
|
"active": aggregator.active_cluster.to_json()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending init state: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
active_connections.discard(websocket)
|
||||||
|
logger.info(f"Client disconnected. Active connections: {len(active_connections)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket error: {e}")
|
||||||
|
active_connections.discard(websocket)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
logger.info(f"Starting YuClusters server on port {settings.WS_PORT}...")
|
||||||
|
uvicorn.run(
|
||||||
|
"backend.server:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=settings.WS_PORT,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# MT5
|
||||||
|
MT5_LOGIN = int(os.environ.get("MT5_LOGIN", 0)) # seu número de conta
|
||||||
|
MT5_PASSWORD = os.environ.get("MT5_PASSWORD", "") # sua senha
|
||||||
|
MT5_SERVER = os.environ.get("MT5_SERVER", "") # nome do servidor (ex: "XPInvestimentos-Real")
|
||||||
|
MT5_SYMBOL = os.environ.get("MT5_SYMBOL", "EURUSD") # símbolo padrão
|
||||||
|
|
||||||
|
# Cluster
|
||||||
|
CLUSTER_RANGE_POINTS = int(os.environ.get("CLUSTER_RANGE_POINTS", 10))
|
||||||
|
CLUSTER_VOLUME_MAX = float(os.environ.get("CLUSTER_VOLUME_MAX", 1000))
|
||||||
|
CLUSTER_DELTA_MAX = float(os.environ.get("CLUSTER_DELTA_MAX", 500))
|
||||||
|
CLUSTER_TIME_SECONDS = float(os.environ.get("CLUSTER_TIME_SECONDS", 60))
|
||||||
|
CLUSTER_CLOSE_MODE = os.environ.get("CLUSTER_CLOSE_MODE", "range") # "range" | "volume" | "delta" | "time"
|
||||||
|
|
||||||
|
# Imbalance
|
||||||
|
IMBALANCE_RATIO = float(os.environ.get("IMBALANCE_RATIO", 3.0))
|
||||||
|
STACKED_MIN_COUNT = int(os.environ.get("STACKED_MIN_COUNT", 3))
|
||||||
|
|
||||||
|
# WebSocket
|
||||||
|
WS_PORT = int(os.environ.get("WS_PORT", 6002))
|
||||||
|
HISTORY_BUFFER_SIZE = int(os.environ.get("HISTORY_BUFFER_SIZE", 50))
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mt5-gateway:
|
||||||
|
build:
|
||||||
|
context: ./docker/mt5-gateway
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: mt5-gateway-service
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
# These variables can be injected via a .env file securely in production
|
||||||
|
- MT5_SERVER=
|
||||||
|
- MT5_LOGIN=0
|
||||||
|
- MT5_PASSWORD=
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1.0'
|
||||||
|
memory: 1G
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
# Prevent interactive prompts
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Add 32-bit architecture for Wine and install dependencies
|
||||||
|
RUN dpkg --add-architecture i386 && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
wine64 \
|
||||||
|
wine32 \
|
||||||
|
xvfb \
|
||||||
|
wget \
|
||||||
|
cabextract \
|
||||||
|
winbind \
|
||||||
|
curl \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up user for Wine
|
||||||
|
RUN useradd -m -s /bin/bash mt5user
|
||||||
|
USER mt5user
|
||||||
|
WORKDIR /home/mt5user
|
||||||
|
|
||||||
|
# Copy scripts
|
||||||
|
COPY --chown=mt5user:mt5user scripts/ /app/scripts/
|
||||||
|
RUN chmod +x /app/scripts/*.sh
|
||||||
|
|
||||||
|
# Run provisioning steps
|
||||||
|
RUN /app/scripts/05_install_python.sh
|
||||||
|
RUN /app/scripts/06_install_libraries.sh
|
||||||
|
RUN /app/scripts/06b_install_mt5.sh
|
||||||
|
|
||||||
|
# Copy application source
|
||||||
|
COPY --chown=mt5user:mt5user app.py /app/app.py
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Start script
|
||||||
|
ENTRYPOINT ["/app/scripts/07_start_wine_flask.sh"]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import os
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
import MetaTrader5 as mt5
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Basic settings from environment or defaults
|
||||||
|
MT5_PATH = os.getenv("MT5_PATH", "C:\\Program Files\\MetaTrader 5\\terminal64.exe")
|
||||||
|
MT5_SERVER = os.getenv("MT5_SERVER", "")
|
||||||
|
MT5_LOGIN = int(os.getenv("MT5_LOGIN", "0"))
|
||||||
|
MT5_PASSWORD = os.getenv("MT5_PASSWORD", "")
|
||||||
|
|
||||||
|
def init_mt5():
|
||||||
|
# If login is provided, connect with credentials
|
||||||
|
if MT5_LOGIN != 0 and MT5_PASSWORD:
|
||||||
|
if not mt5.initialize(path=MT5_PATH, login=MT5_LOGIN, server=MT5_SERVER, password=MT5_PASSWORD):
|
||||||
|
return False, mt5.last_error()
|
||||||
|
else:
|
||||||
|
# Just initialize whatever is there
|
||||||
|
if not mt5.initialize(path=MT5_PATH):
|
||||||
|
return False, mt5.last_error()
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
success, error = init_mt5()
|
||||||
|
if not success:
|
||||||
|
return jsonify({"status": "error", "message": "Failed to connect to MT5", "error_code": error}), 500
|
||||||
|
|
||||||
|
info = mt5.terminal_info()
|
||||||
|
if info is None:
|
||||||
|
return jsonify({"status": "error", "message": "Failed to get terminal info"}), 500
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "ok",
|
||||||
|
"terminal_connected": info.connected,
|
||||||
|
"trade_allowed": info.trade_allowed,
|
||||||
|
"build": info.build
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/symbol/<ticker>', methods=['GET'])
|
||||||
|
def symbol_info(ticker):
|
||||||
|
init_mt5()
|
||||||
|
info = mt5.symbol_info(ticker)
|
||||||
|
if info is None:
|
||||||
|
return jsonify({"status": "error", "message": f"Symbol {ticker} not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"symbol": info.name,
|
||||||
|
"bid": info.bid,
|
||||||
|
"ask": info.ask,
|
||||||
|
"spread": info.spread,
|
||||||
|
"trade_mode": info.trade_mode
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/order', methods=['POST'])
|
||||||
|
def place_order():
|
||||||
|
init_mt5()
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
# Very basic order payload (can be extended with full Swagger spec later)
|
||||||
|
# Expects: {"symbol": "EURUSD", "action": "buy", "volume": 1.0}
|
||||||
|
symbol = data.get("symbol")
|
||||||
|
action = data.get("action")
|
||||||
|
volume = float(data.get("volume", 0.0))
|
||||||
|
|
||||||
|
if action == "buy":
|
||||||
|
type = mt5.ORDER_TYPE_BUY
|
||||||
|
price = mt5.symbol_info_tick(symbol).ask
|
||||||
|
else:
|
||||||
|
type = mt5.ORDER_TYPE_SELL
|
||||||
|
price = mt5.symbol_info_tick(symbol).bid
|
||||||
|
|
||||||
|
order_request = {
|
||||||
|
"action": mt5.TRADE_ACTION_DEAL,
|
||||||
|
"symbol": symbol,
|
||||||
|
"volume": volume,
|
||||||
|
"type": type,
|
||||||
|
"price": price,
|
||||||
|
"deviation": 20,
|
||||||
|
"magic": 234000,
|
||||||
|
"comment": "python api",
|
||||||
|
"type_time": mt5.ORDER_TIME_GTC,
|
||||||
|
"type_filling": mt5.ORDER_FILLING_IOC,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = mt5.order_send(order_request)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return jsonify({"status": "error", "message": "Order failed entirely", "error": mt5.last_error()}), 500
|
||||||
|
|
||||||
|
# Translate MT5 Return Codes to friendly API responses
|
||||||
|
# Mapping can be expanded as needed
|
||||||
|
if result.retcode == mt5.TRADE_RETCODE_DONE:
|
||||||
|
return jsonify({"status": "ok", "retcode": result.retcode, "deal": result.deal, "message": "Order placed successfully"})
|
||||||
|
elif result.retcode == mt5.TRADE_RETCODE_MARKET_CLOSED:
|
||||||
|
return jsonify({"status": "error", "retcode": result.retcode, "message": "Market is closed"}), 400
|
||||||
|
else:
|
||||||
|
return jsonify({"status": "error", "retcode": result.retcode, "message": "Order failed", "comment": result.comment}), 400
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5000)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Installing Python for Windows via Wine ==="
|
||||||
|
# We need to install the Windows version of Python inside Wine for MT5 library compatibility
|
||||||
|
WINEPREFIX=$HOME/.wine wine msiexec /i https://www.python.org/ftp/python/3.10.11/python-3.10.11-amd64.msi /quiet InstallAllUsers=1 PrependPath=1 Include_test=0
|
||||||
|
|
||||||
|
# Verify python installation
|
||||||
|
WINEPREFIX=$HOME/.wine wine python --version
|
||||||
|
echo "Python installed successfully."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Installing Python Libraries via Wine ==="
|
||||||
|
# Ensure pip is up to date
|
||||||
|
WINEPREFIX=$HOME/.wine wine python -m pip install --upgrade pip
|
||||||
|
|
||||||
|
# Install Flask and MetaTrader5
|
||||||
|
# The user explicitly warned to be careful with the case sensitivity of MetaTrader5!
|
||||||
|
WINEPREFIX=$HOME/.wine wine python -m pip install Flask MetaTrader5
|
||||||
|
|
||||||
|
echo "Libraries installed successfully."
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Downloading and Installing MetaTrader 5 ==="
|
||||||
|
export WINEPREFIX=$HOME/.wine
|
||||||
|
export WINEDLLOVERRIDES="mscoree,mshtml="
|
||||||
|
|
||||||
|
# Start Xvfb temporarily for the installation
|
||||||
|
# Some silent Windows installers still crash if there's no display available
|
||||||
|
Xvfb :99 -screen 0 1024x768x16 &
|
||||||
|
XVFB_PID=$!
|
||||||
|
export DISPLAY=:99
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Download MT5 setup from MetaQuotes official CDN
|
||||||
|
wget -O mt5setup.exe "https://download.mql5.com/cdn/web/metaquotes.software.corp/mt5/mt5setup.exe"
|
||||||
|
|
||||||
|
# Install silently ( /auto )
|
||||||
|
echo "Installing MT5 silently (this may take a minute)..."
|
||||||
|
wine mt5setup.exe /auto
|
||||||
|
|
||||||
|
# Wait for background installation tasks to complete and shutdown Wine safely
|
||||||
|
wineserver -w
|
||||||
|
kill $XVFB_PID || true
|
||||||
|
rm mt5setup.exe
|
||||||
|
|
||||||
|
echo "MT5 installed successfully."
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Starting MT5 Gateway ==="
|
||||||
|
export WINEPREFIX=$HOME/.wine
|
||||||
|
export WINEDLLOVERRIDES="mscoree,mshtml="
|
||||||
|
|
||||||
|
# Start Xvfb in background
|
||||||
|
Xvfb :0 -screen 0 1024x768x16 &
|
||||||
|
export DISPLAY=:0
|
||||||
|
|
||||||
|
# Wait for X11
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# We start the Flask server via Python in Wine
|
||||||
|
# The MT5 logic inside app.py will initialize MT5
|
||||||
|
echo "Starting Flask API Bridge..."
|
||||||
|
wine python /app/app.py
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="h-full bg-darkBg">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📊</text></svg>" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>YuClusters Local — Pro Footprint Chart</title>
|
||||||
|
<!-- Outfit & Inter Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="h-full text-slate-100 font-sans selection:bg-neonGreen/20 selection:text-neonGreen antialiased overflow-hidden">
|
||||||
|
<div id="root" class="h-full"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2665
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "yuclusters-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useWebSocket } from './useWebSocket';
|
||||||
|
import FootprintCanvas from './FootprintCanvas';
|
||||||
|
import AlertEngine from './utils/AlertEngine';
|
||||||
|
|
||||||
|
const BACKEND_PORT = 6002;
|
||||||
|
const WS_URL = `ws://localhost:${BACKEND_PORT}/ws`;
|
||||||
|
const API_URL = `http://localhost:${BACKEND_PORT}`;
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [activeCluster, setActiveCluster] = useState(null);
|
||||||
|
const [lastTickTime, setLastTickTime] = useState(null);
|
||||||
|
|
||||||
|
// Phase 2 & 4 Settings
|
||||||
|
const [stepMultiplier, setStepMultiplier] = useState(1);
|
||||||
|
const [viewMode, setViewMode] = useState('bidask'); // 'bidask' or 'delta'
|
||||||
|
const [imbalanceRatio, setImbalanceRatio] = useState(300); // percentage
|
||||||
|
|
||||||
|
// Toasts
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
const pushToast = useCallback((msg, type = 'info') => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setToasts(prev => [...prev, { id, msg, type }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch initial history when backend becomes reachable
|
||||||
|
const fetchHistory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/history`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setHistory(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Could not fetch cluster history:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Message Handler for WebSocket
|
||||||
|
const handleWebSocketMessage = useCallback((msg) => {
|
||||||
|
setLastTickTime(new Date());
|
||||||
|
|
||||||
|
if (msg.type === 'init') {
|
||||||
|
setActiveCluster(msg.active);
|
||||||
|
fetchHistory(); // Sync history on connection
|
||||||
|
} else if (msg.type === 'tick') {
|
||||||
|
setActiveCluster(msg.active);
|
||||||
|
// If a cluster has just closed, we receive the closed state
|
||||||
|
if (msg.closed) {
|
||||||
|
setHistory(prev => {
|
||||||
|
const updated = [...prev, msg.closed];
|
||||||
|
if (updated.length > 50) {
|
||||||
|
updated.shift();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch alerts for the newly closed cluster
|
||||||
|
AlertEngine.processClusters([msg.closed], pushToast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fetchHistory]);
|
||||||
|
|
||||||
|
const wsStatus = useWebSocket(WS_URL, handleWebSocketMessage);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHistory();
|
||||||
|
}, [fetchHistory, wsStatus]);
|
||||||
|
|
||||||
|
// Aggregate stats from history
|
||||||
|
const totalVolume = history.reduce((acc, c) => acc + (c.total_volume || 0), 0) + (activeCluster?.total_volume || 0);
|
||||||
|
const avgVolumePerCluster = history.length > 0 ? (history.reduce((acc, c) => acc + (c.total_volume || 0), 0) / history.length).toFixed(0) : 0;
|
||||||
|
|
||||||
|
const allClusters = [...history];
|
||||||
|
if (activeCluster) allClusters.push(activeCluster);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-[#0B0E14] text-slate-100 relative">
|
||||||
|
{/* Toast Container */}
|
||||||
|
<div className="absolute top-4 right-4 z-50 flex flex-col gap-2">
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div key={t.id} className={`px-4 py-3 rounded-md shadow-lg font-medium text-sm flex items-center gap-2 border ${
|
||||||
|
t.type === 'error' ? 'bg-red-500/10 border-red-500 text-red-500' :
|
||||||
|
t.type === 'warning' ? 'bg-amber-500/10 border-amber-500 text-amber-400' :
|
||||||
|
'bg-blue-500/10 border-blue-500 text-blue-400'
|
||||||
|
}`}>
|
||||||
|
<span>{t.type === 'error' ? '🚨' : t.type === 'warning' ? '⚠️' : 'ℹ️'}</span>
|
||||||
|
{t.msg}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Premium Header */}
|
||||||
|
<header className="flex items-center justify-between px-6 py-4 bg-[#151B26] border-b border-slate-800 shadow-md">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 bg-gradient-to-tr from-[#00E676] to-[#00B0FF] rounded-lg flex items-center justify-center font-bold text-lg text-white shadow-lg">
|
||||||
|
Yu
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold tracking-tight bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">
|
||||||
|
YuClusters Local
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400">Order Flow Footprint Analyzer 2.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Status indicator */}
|
||||||
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
{lastTickTime && (
|
||||||
|
<div className="text-slate-400 text-xs hidden sm:block">
|
||||||
|
Last Update: <span className="font-mono text-slate-300">{lastTickTime.toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">MT5 Bridge:</span>
|
||||||
|
<div className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold ${
|
||||||
|
wsStatus === 'connected'
|
||||||
|
? 'bg-[#00E676]/10 text-[#00E676] border border-[#00E676]/20'
|
||||||
|
: wsStatus === 'connecting'
|
||||||
|
? 'bg-amber-500/10 text-amber-500 border border-amber-500/20'
|
||||||
|
: 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
wsStatus === 'connected' ? 'bg-[#00E676] animate-pulse' : wsStatus === 'connecting' ? 'bg-amber-500 animate-pulse' : 'bg-red-500'
|
||||||
|
}`} />
|
||||||
|
{wsStatus.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Body Layout */}
|
||||||
|
<main className="flex-1 flex overflow-hidden p-6 gap-6">
|
||||||
|
{/* Footprint Chart Panel */}
|
||||||
|
<div className="flex-1 flex flex-col h-full bg-[#151B26]/30 rounded-xl overflow-hidden">
|
||||||
|
<FootprintCanvas
|
||||||
|
clusters={allClusters}
|
||||||
|
tickSize={0.00001}
|
||||||
|
stepMultiplier={stepMultiplier}
|
||||||
|
viewMode={viewMode}
|
||||||
|
imbalanceRatio={imbalanceRatio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Sidebar Panel */}
|
||||||
|
<aside className="w-80 flex flex-col gap-6 hidden lg:flex">
|
||||||
|
{/* Active Statistics Card */}
|
||||||
|
<section className="bg-[#151B26] border border-slate-800 rounded-xl p-5 shadow-lg">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-300 mb-4 border-b border-slate-800 pb-2">
|
||||||
|
System Overview
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-slate-400">Target Symbol:</span>
|
||||||
|
<span className="font-mono font-semibold text-[#00B0FF] bg-[#00B0FF]/10 px-2 py-0.5 rounded">
|
||||||
|
EURUSD
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs text-slate-500 font-medium">TOTAL VOLUME</span>
|
||||||
|
<span className="text-lg font-bold text-slate-200">{(totalVolume / 1000).toFixed(1)}k</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs text-slate-500 font-medium">AVG VOL / CLUSTER</span>
|
||||||
|
<span className="text-lg font-bold text-slate-200">{avgVolumePerCluster}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-8 w-px bg-slate-800 hidden sm:block"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] text-slate-500 font-bold mb-1">PRICE STEP</span>
|
||||||
|
<div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
|
||||||
|
<button onClick={() => setStepMultiplier(Math.max(1, stepMultiplier - 1))} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">-</button>
|
||||||
|
<div className="px-3 py-1 text-sm font-bold text-white min-w-[30px] text-center">{stepMultiplier}</div>
|
||||||
|
<button onClick={() => setStepMultiplier(stepMultiplier + 1)} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] text-slate-500 font-bold mb-1">VIEW MODE</span>
|
||||||
|
<div className="flex bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('bidask')}
|
||||||
|
className={`px-3 py-1 text-xs font-semibold ${viewMode === 'bidask' ? 'bg-[#00E676] text-slate-900' : 'text-slate-400 hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
Bid x Ask
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('delta')}
|
||||||
|
className={`px-3 py-1 text-xs font-semibold ${viewMode === 'delta' ? 'bg-[#00E676] text-slate-900' : 'text-slate-400 hover:bg-slate-800'}`}
|
||||||
|
>
|
||||||
|
Delta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mt-4">
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="flex justify-between mb-1">
|
||||||
|
<span className="text-[10px] text-slate-500 font-bold">IMBALANCE RATIO</span>
|
||||||
|
<span className="text-[10px] font-mono text-slate-300">{imbalanceRatio}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="150"
|
||||||
|
max="500"
|
||||||
|
step="10"
|
||||||
|
value={imbalanceRatio}
|
||||||
|
onChange={(e) => setImbalanceRatio(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-[#00B0FF] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-xs mt-4 border-t border-slate-800 pt-4">
|
||||||
|
<span className="text-slate-400">Closed Clusters:</span>
|
||||||
|
<span className="font-mono text-slate-200">{history.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Aggregator Settings Rules Indicator */}
|
||||||
|
<section className="bg-[#151B26] border border-slate-800 rounded-xl p-5 shadow-lg flex-1">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-300 mb-4 border-b border-slate-800 pb-2">
|
||||||
|
Aggregator Rules
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="text-xs space-y-3.5 text-slate-400">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-300 font-medium mb-1">Diagonal Imbalance</p>
|
||||||
|
<code className="block bg-[#0B0E14] p-2 rounded text-[10px] text-slate-400 leading-relaxed font-mono">
|
||||||
|
BUY: ask[i] ≥ 3.0 × bid[i-1]<br />
|
||||||
|
SELL: bid[i] ≥ 3.0 × ask[i+1]
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-300 font-medium mb-1">Stacked Imbalance Zone</p>
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
Triggered on <span className="text-white font-semibold">3+</span> consecutive diagonal imbalances in the same direction. Highlighting horizontal zones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-300 font-medium mb-1">POC (Point of Control)</p>
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
Level containing the highest total volume within the cluster. Highlighted with a <span className="text-[#FFD600] font-semibold">Gold border</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Quick Guide Footer */}
|
||||||
|
<footer className="text-[11px] text-slate-500 text-center leading-relaxed">
|
||||||
|
Drag to pan horizontally & vertically.<br />
|
||||||
|
Use scroll wheel to move vertical scale.<br />
|
||||||
|
Hold Shift + scroll wheel to scroll horizontal.
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300 }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
|
// Navigation & Scale State
|
||||||
|
const [scrollOffset, setScrollOffset] = useState({ x: 50, y: 0 }); // X: horizontal offset, Y: vertical offset
|
||||||
|
const [zoom, setZoom] = useState(1); // Zoom level
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragStart = useRef({ x: 0, y: 0 });
|
||||||
|
const dragOffsetStart = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Apply zoom to sizes
|
||||||
|
const colWidth = 140 * zoom; // width of each cluster column
|
||||||
|
const colGap = 15 * zoom; // gap between columns
|
||||||
|
const rowHeight = 26 * zoom; // height of each price cell
|
||||||
|
const axisWidth = 70; // width of the vertical price axis on the right
|
||||||
|
|
||||||
|
// Handle auto-scroll to the right (most recent cluster) on new cluster load
|
||||||
|
const lastClusterCount = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusters && clusters.length > lastClusterCount.current && canvasRef.current) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
// Scroll to show the active cluster at the right side
|
||||||
|
const rightmostX = canvas.width - axisWidth - (clusters.length * (colWidth + colGap)) - 50;
|
||||||
|
setScrollOffset(prev => ({ ...prev, x: Math.min(160, rightmostX) }));
|
||||||
|
lastClusterCount.current = clusters.length;
|
||||||
|
}
|
||||||
|
}, [clusters?.length]);
|
||||||
|
|
||||||
|
// Main Render Loop
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Handle High DPI displays
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const width = rect.width;
|
||||||
|
const height = rect.height;
|
||||||
|
|
||||||
|
// Split View Layout
|
||||||
|
const bottomPanelHeight = 100;
|
||||||
|
const chartHeight = height - bottomPanelHeight;
|
||||||
|
|
||||||
|
// Clear screen
|
||||||
|
ctx.fillStyle = '#0B0E14';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Draw Grid Background
|
||||||
|
ctx.strokeStyle = '#151B26';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let x = 0; x < width; x += 50) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 0; y < height; y += 50) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clusters || clusters.length === 0) {
|
||||||
|
ctx.fillStyle = '#64748B';
|
||||||
|
ctx.font = '16px Outfit, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('Waiting for market data from MetaTrader 5...', width / 2, height / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the baseline price to align Y coordinates
|
||||||
|
const latestCluster = clusters[clusters.length - 1];
|
||||||
|
const basePrice = latestCluster.poc || 0;
|
||||||
|
const actualTickSize = (latestCluster.tick_size || tickSize) * stepMultiplier;
|
||||||
|
const centerY = chartHeight / 2 + scrollOffset.y;
|
||||||
|
|
||||||
|
// Helper to get Y coordinate for a given price
|
||||||
|
const getPriceY = (price) => {
|
||||||
|
const diffTicks = (price - basePrice) / actualTickSize;
|
||||||
|
return centerY - (diffTicks * rowHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get price from Y coordinate (for axis rendering)
|
||||||
|
const getYPrice = (y) => {
|
||||||
|
const diffTicks = (centerY - y) / rowHeight;
|
||||||
|
return basePrice + (diffTicks * actualTickSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cumulative Delta Tracking
|
||||||
|
let cumulativeDelta = 0;
|
||||||
|
|
||||||
|
// Draw Columns (Clusters)
|
||||||
|
clusters.forEach((cluster, index) => {
|
||||||
|
// Calculate column X position
|
||||||
|
// Offset starting after the Volume Profile panel (width 140)
|
||||||
|
const colX = scrollOffset.x + index * (colWidth + colGap);
|
||||||
|
|
||||||
|
// Don't render if outside canvas bounds (horizontal clipping)
|
||||||
|
if (colX + colWidth < 0 || colX > width - axisWidth) return;
|
||||||
|
|
||||||
|
let levels = cluster.levels || {};
|
||||||
|
|
||||||
|
// Dynamic Binning based on stepMultiplier
|
||||||
|
if (stepMultiplier > 1) {
|
||||||
|
const binnedLevels = {};
|
||||||
|
Object.keys(levels).forEach(pStr => {
|
||||||
|
const p = parseFloat(pStr);
|
||||||
|
const data = levels[pStr];
|
||||||
|
const binPrice = Math.round(p / actualTickSize) * actualTickSize;
|
||||||
|
|
||||||
|
if (!binnedLevels[binPrice]) {
|
||||||
|
binnedLevels[binPrice] = { ask: 0, bid: 0, total: 0, delta: 0, imbalance: null };
|
||||||
|
}
|
||||||
|
binnedLevels[binPrice].ask += data.ask || 0;
|
||||||
|
binnedLevels[binPrice].bid += data.bid || 0;
|
||||||
|
binnedLevels[binPrice].total += data.total || 0;
|
||||||
|
binnedLevels[binPrice].delta += data.delta || 0;
|
||||||
|
|
||||||
|
const ratio = imbalanceRatio / 100.0;
|
||||||
|
if (binnedLevels[binPrice].ask >= binnedLevels[binPrice].bid * ratio && binnedLevels[binPrice].ask > 0) {
|
||||||
|
binnedLevels[binPrice].imbalance = 'buy';
|
||||||
|
} else if (binnedLevels[binPrice].bid >= binnedLevels[binPrice].ask * ratio && binnedLevels[binPrice].bid > 0) {
|
||||||
|
binnedLevels[binPrice].imbalance = 'sell';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
levels = binnedLevels;
|
||||||
|
}
|
||||||
|
// Sort string keys numerically, but keep them as strings to avoid trailing zero lookup issues
|
||||||
|
const pricesStr = Object.keys(levels).sort((a, b) => Number(b) - Number(a));
|
||||||
|
|
||||||
|
if (pricesStr.length === 0) return;
|
||||||
|
|
||||||
|
const numericPrices = pricesStr.map(Number);
|
||||||
|
const highestPrice = Math.max(...numericPrices);
|
||||||
|
const lowestPrice = Math.min(...numericPrices);
|
||||||
|
|
||||||
|
// Draw Stacked Imbalance background zone if present
|
||||||
|
if (cluster.stacked && (cluster.stacked.buy || cluster.stacked.sell)) {
|
||||||
|
const stackedPrices = cluster.stacked.price_range || [];
|
||||||
|
if (stackedPrices.length > 0) {
|
||||||
|
const sHigh = Math.max(...stackedPrices);
|
||||||
|
const sLow = Math.min(...stackedPrices);
|
||||||
|
const yTop = getPriceY(sHigh) - rowHeight / 2;
|
||||||
|
const yBottom = getPriceY(sLow) + rowHeight / 2;
|
||||||
|
|
||||||
|
const grad = ctx.createLinearGradient(colX - 8, yTop, colX, yTop);
|
||||||
|
if (cluster.stacked.buy) {
|
||||||
|
grad.addColorStop(0, 'rgba(0, 230, 118, 0.4)');
|
||||||
|
grad.addColorStop(1, 'rgba(0, 230, 118, 0.05)');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
} else {
|
||||||
|
grad.addColorStop(0, 'rgba(255, 23, 68, 0.4)');
|
||||||
|
grad.addColorStop(1, 'rgba(255, 23, 68, 0.05)');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
}
|
||||||
|
ctx.fillRect(colX - 10, yTop, 10, yBottom - yTop);
|
||||||
|
|
||||||
|
// Draw thin outline
|
||||||
|
ctx.strokeStyle = cluster.stacked.buy ? '#00E676' : '#FF1744';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(colX - 10, yTop);
|
||||||
|
ctx.lineTo(colX - 10, yBottom);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Cluster Header (Info Card at the top)
|
||||||
|
const headerY = getPriceY(highestPrice) - rowHeight - 35;
|
||||||
|
|
||||||
|
// Header Background
|
||||||
|
ctx.fillStyle = 'rgba(21, 27, 38, 0.85)';
|
||||||
|
ctx.strokeStyle = cluster.status === 'active' ? 'rgba(0, 230, 118, 0.4)' : '#2A364F';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(colX, headerY, colWidth, 45, 6);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Header Text
|
||||||
|
ctx.fillStyle = '#94A3B8';
|
||||||
|
ctx.font = '10px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
|
||||||
|
const pattern = cluster.advanced_metrics?.pattern;
|
||||||
|
const divergence = cluster.advanced_metrics?.delta_divergence;
|
||||||
|
|
||||||
|
let patternTag = '';
|
||||||
|
if (pattern === 'P') patternTag = '[P] ';
|
||||||
|
if (pattern === 'B') patternTag = '[B] ';
|
||||||
|
|
||||||
|
// Divergence Tag
|
||||||
|
if (divergence) patternTag += '⚠️ ';
|
||||||
|
|
||||||
|
// Volume & Delta
|
||||||
|
const volK = (cluster.total_volume || 0).toFixed(0);
|
||||||
|
const deltaStr = (cluster.total_delta >= 0 ? '+' : '') + (cluster.total_delta || 0).toFixed(0);
|
||||||
|
|
||||||
|
ctx.fillText(`${patternTag}VOL: ${volK}`, colX + 8, headerY + 18);
|
||||||
|
ctx.fillStyle = cluster.total_delta >= 0 ? '#00E676' : '#FF1744';
|
||||||
|
ctx.fillText(`DEL: ${deltaStr}`, colX + 8, headerY + 32);
|
||||||
|
|
||||||
|
// Time or reason
|
||||||
|
ctx.fillStyle = '#64748B';
|
||||||
|
const timeStr = cluster.open_time ? new Date(cluster.open_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--:--:--';
|
||||||
|
ctx.fillText(timeStr, colX + colWidth - 55, headerY + 18);
|
||||||
|
|
||||||
|
// Find max volume in this cluster to calculate relative opacities
|
||||||
|
const maxTotalVolumeInCluster = Math.max(...Object.values(levels).map(l => l.total || 1));
|
||||||
|
|
||||||
|
// OHLC Candlestick Skeleton (Background)
|
||||||
|
if (cluster.open_price !== undefined && cluster.close_price !== undefined) {
|
||||||
|
const isBull = cluster.close_price >= cluster.open_price;
|
||||||
|
ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.4)' : 'rgba(255, 23, 68, 0.4)';
|
||||||
|
ctx.fillStyle = isBull ? 'rgba(0, 230, 118, 0.1)' : 'rgba(255, 23, 68, 0.1)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const openY = getPriceY(cluster.open_price);
|
||||||
|
const closeY = getPriceY(cluster.close_price);
|
||||||
|
const highY = getPriceY(highestPrice);
|
||||||
|
const lowY = getPriceY(lowestPrice);
|
||||||
|
|
||||||
|
const candleTop = Math.min(openY, closeY) - rowHeight / 2;
|
||||||
|
const candleBottom = Math.max(openY, closeY) + rowHeight / 2;
|
||||||
|
const bodyHeight = Math.max(2, candleBottom - candleTop);
|
||||||
|
|
||||||
|
// Draw Wick (Pavio)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(colX + colWidth / 2, highY - rowHeight / 2);
|
||||||
|
ctx.lineTo(colX + colWidth / 2, lowY + rowHeight / 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw Body Background
|
||||||
|
ctx.fillRect(colX - 4, candleTop, colWidth + 8, bodyHeight);
|
||||||
|
ctx.strokeRect(colX - 4, candleTop, colWidth + 8, bodyHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Cells
|
||||||
|
pricesStr.forEach((priceStr, i) => {
|
||||||
|
const price = Number(priceStr);
|
||||||
|
const cellData = levels[priceStr];
|
||||||
|
const cellY = getPriceY(price) - rowHeight / 2;
|
||||||
|
|
||||||
|
// Skip if vertically out of bounds
|
||||||
|
if (cellY + rowHeight < 0 || cellY > height) return;
|
||||||
|
|
||||||
|
const bid = cellData.bid || 0;
|
||||||
|
const ask = cellData.ask || 0;
|
||||||
|
const total = cellData.total || 0;
|
||||||
|
|
||||||
|
// Dynamic Imbalance Calculation (Frontend)
|
||||||
|
const ratio = imbalanceRatio / 100.0;
|
||||||
|
let dynImbalance = null;
|
||||||
|
|
||||||
|
// ask vs lower bid
|
||||||
|
const lowerData = i + 1 < pricesStr.length ? levels[pricesStr[i + 1]] : null;
|
||||||
|
const lowerBid = lowerData ? (lowerData.bid || 0) : 0;
|
||||||
|
const isBuyImbalance = ask >= lowerBid * ratio && ask > 0;
|
||||||
|
|
||||||
|
// bid vs higher ask
|
||||||
|
const upperData = i - 1 >= 0 ? levels[pricesStr[i - 1]] : null;
|
||||||
|
const upperAsk = upperData ? (upperData.ask || 0) : 0;
|
||||||
|
const isSellImbalance = bid >= upperAsk * ratio && bid > 0;
|
||||||
|
|
||||||
|
if (isBuyImbalance && isSellImbalance) dynImbalance = 'both';
|
||||||
|
else if (isBuyImbalance) dynImbalance = 'buy';
|
||||||
|
else if (isSellImbalance) dynImbalance = 'sell';
|
||||||
|
|
||||||
|
// Volume-based opacity
|
||||||
|
const relOpacity = maxTotalVolumeInCluster > 0 ? (total / maxTotalVolumeInCluster) : 0;
|
||||||
|
|
||||||
|
// Base fill color with opacity
|
||||||
|
let cellColor = `rgba(59, 130, 246, ${0.05 + relOpacity * 0.25})`; // Dark Slate Blue default
|
||||||
|
if (ask >= bid * ratio && ask > 0) {
|
||||||
|
cellColor = `rgba(0, 230, 118, ${0.2 + relOpacity * 0.5})`; // Strong Green Heatmap (Horizontal)
|
||||||
|
} else if (bid >= ask * ratio && bid > 0) {
|
||||||
|
cellColor = `rgba(255, 23, 68, ${0.2 + relOpacity * 0.5})`; // Strong Red Heatmap (Horizontal)
|
||||||
|
} else if (dynImbalance === 'buy') {
|
||||||
|
cellColor = `rgba(0, 230, 118, ${0.1 + relOpacity * 0.35})`; // Neon Green tint
|
||||||
|
} else if (dynImbalance === 'sell') {
|
||||||
|
cellColor = `rgba(255, 23, 68, ${0.1 + relOpacity * 0.35})`; // Neon Red tint
|
||||||
|
} else if (dynImbalance === 'both') {
|
||||||
|
cellColor = `rgba(168, 85, 247, ${0.1 + relOpacity * 0.35})`; // Purple
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = cellColor;
|
||||||
|
ctx.fillRect(colX, cellY, colWidth, rowHeight - 2);
|
||||||
|
|
||||||
|
// Imbalance border outline
|
||||||
|
if (dynImbalance === 'buy') {
|
||||||
|
ctx.strokeStyle = 'rgba(0, 230, 118, 0.8)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(colX + 0.5, cellY + 0.5, colWidth - 1, rowHeight - 3);
|
||||||
|
} else if (dynImbalance === 'sell') {
|
||||||
|
ctx.strokeStyle = 'rgba(255, 23, 68, 0.8)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(colX + 0.5, cellY + 0.5, colWidth - 1, rowHeight - 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw POC outline (Thick Gold Border)
|
||||||
|
if (price === cluster.poc) {
|
||||||
|
ctx.strokeStyle = '#FFD600';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(colX + 1, cellY + 1, colWidth - 2, rowHeight - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Text (Bid x Ask OR Delta)
|
||||||
|
const fontSize = Math.max(6, 11 * zoom);
|
||||||
|
ctx.font = `${fontSize}px JetBrains Mono, monospace`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
if (dynImbalance === 'buy') {
|
||||||
|
ctx.fillStyle = '#00E676';
|
||||||
|
} else if (dynImbalance === 'sell') {
|
||||||
|
ctx.fillStyle = '#FF1744';
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = '#E2E8F0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide text if zoomed out too much to avoid clutter
|
||||||
|
if (zoom >= 0.5) {
|
||||||
|
if (viewMode === 'delta') {
|
||||||
|
const deltaStr = (cellData.delta >= 0 ? '+' : '') + (cellData.delta || 0).toFixed(0);
|
||||||
|
ctx.fillText(deltaStr, colX + colWidth / 2, cellY + rowHeight / 2);
|
||||||
|
} else {
|
||||||
|
ctx.fillText(`${bid.toFixed(0)} × ${ask.toFixed(0)}`, colX + colWidth / 2, cellY + rowHeight / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render Extremes Ratios (Informers)
|
||||||
|
const adv = cluster.advanced_metrics;
|
||||||
|
if (adv) {
|
||||||
|
ctx.font = '10px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
const highY = getPriceY(highestPrice) - rowHeight;
|
||||||
|
const lowY = getPriceY(lowestPrice) + rowHeight;
|
||||||
|
|
||||||
|
// Top Ratio
|
||||||
|
if (adv.high_ratio !== undefined) {
|
||||||
|
const hRatio = adv.high_ratio;
|
||||||
|
ctx.fillStyle = hRatio < 0.7 ? '#FFD600' : '#64748B'; // Gold if exhaustion
|
||||||
|
ctx.fillText(hRatio.toFixed(2), colX + colWidth / 2, highY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom Ratio
|
||||||
|
if (adv.low_ratio !== undefined) {
|
||||||
|
const lRatio = adv.low_ratio;
|
||||||
|
ctx.fillStyle = lRatio < 0.7 ? '#FFD600' : '#64748B'; // Gold if exhaustion
|
||||||
|
ctx.fillText(lRatio.toFixed(2), colX + colWidth / 2, lowY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Top/Bottom Extreme Markers
|
||||||
|
if (adv) {
|
||||||
|
if (adv.top_extreme === 'exhaustion' || adv.top_extreme === 'absorption') {
|
||||||
|
const topY = getPriceY(highestPrice) - rowHeight / 2;
|
||||||
|
ctx.strokeStyle = adv.top_extreme === 'absorption' ? '#FF9800' : '#2196F3'; // Orange for Absorption, Blue for Exhaustion
|
||||||
|
ctx.lineWidth = adv.top_extreme === 'absorption' ? 3 : 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(colX, topY);
|
||||||
|
ctx.lineTo(colX + colWidth, topY);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
if (adv.bottom_extreme === 'exhaustion' || adv.bottom_extreme === 'absorption') {
|
||||||
|
const bottomY = getPriceY(lowestPrice) + rowHeight / 2;
|
||||||
|
ctx.strokeStyle = adv.bottom_extreme === 'absorption' ? '#FF9800' : '#2196F3';
|
||||||
|
ctx.lineWidth = adv.bottom_extreme === 'absorption' ? 3 : 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(colX, bottomY);
|
||||||
|
ctx.lineTo(colX + colWidth, bottomY);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Cumulative Delta for bottom panel
|
||||||
|
cumulativeDelta += (cluster.total_delta || 0);
|
||||||
|
|
||||||
|
// Bottom Panel (Delta Histogram)
|
||||||
|
const panelY = height - bottomPanelHeight;
|
||||||
|
const cvdBaseline = panelY + bottomPanelHeight / 2;
|
||||||
|
|
||||||
|
// Delta cluster bar
|
||||||
|
const deltaVol = cluster.total_delta || 0;
|
||||||
|
const deltaColor = deltaVol >= 0 ? 'rgba(0, 230, 118, 0.7)' : 'rgba(255, 23, 68, 0.7)';
|
||||||
|
ctx.fillStyle = deltaColor;
|
||||||
|
|
||||||
|
// Scale: 1000 volume = 20px
|
||||||
|
const scaleFactor = 30 / 1000;
|
||||||
|
const barH = Math.min(Math.abs(deltaVol) * scaleFactor, bottomPanelHeight / 2 - 5);
|
||||||
|
const startY = deltaVol >= 0 ? cvdBaseline - barH : cvdBaseline;
|
||||||
|
ctx.fillRect(colX + 5, startY, colWidth - 10, barH);
|
||||||
|
|
||||||
|
// CVD line (Cumulative Delta)
|
||||||
|
ctx.fillStyle = cumulativeDelta >= 0 ? '#00E676' : '#FF1744';
|
||||||
|
ctx.font = '10px JetBrains Mono, monospace';
|
||||||
|
ctx.fillText(`CVD: ${cumulativeDelta.toFixed(0)}`, colX + colWidth / 2, panelY + 15);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw Volume Profile Overlay Panel (Left Side)
|
||||||
|
const volProfileWidth = 140;
|
||||||
|
const volumeProfile = {};
|
||||||
|
let maxProfileVolume = 0;
|
||||||
|
clusters.forEach(cluster => {
|
||||||
|
const lvls = cluster.levels || {};
|
||||||
|
Object.keys(lvls).forEach(pStr => {
|
||||||
|
const p = parseFloat(pStr);
|
||||||
|
const data = lvls[pStr];
|
||||||
|
const binPrice = Math.round(p / actualTickSize) * actualTickSize;
|
||||||
|
volumeProfile[binPrice] = (volumeProfile[binPrice] || 0) + (data.total || 0);
|
||||||
|
if (volumeProfile[binPrice] > maxProfileVolume) {
|
||||||
|
maxProfileVolume = volumeProfile[binPrice];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Value Area Calculation (70% of total volume)
|
||||||
|
let totalDayVolume = 0;
|
||||||
|
let dayPocPrice = null;
|
||||||
|
let dayPocVol = -1;
|
||||||
|
|
||||||
|
Object.keys(volumeProfile).forEach(pStr => {
|
||||||
|
const vol = volumeProfile[pStr];
|
||||||
|
totalDayVolume += vol;
|
||||||
|
if (vol > dayPocVol) {
|
||||||
|
dayPocVol = vol;
|
||||||
|
dayPocPrice = parseFloat(pStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let vah = dayPocPrice;
|
||||||
|
let val = dayPocPrice;
|
||||||
|
|
||||||
|
if (totalDayVolume > 0 && dayPocPrice !== null) {
|
||||||
|
let currentValVol = dayPocVol;
|
||||||
|
const targetVol = totalDayVolume * 0.70;
|
||||||
|
let upperPrice = dayPocPrice + actualTickSize;
|
||||||
|
let lowerPrice = dayPocPrice - actualTickSize;
|
||||||
|
|
||||||
|
while (currentValVol < targetVol) {
|
||||||
|
const upperVol = volumeProfile[upperPrice.toString()] || 0;
|
||||||
|
const lowerVol = volumeProfile[lowerPrice.toString()] || 0;
|
||||||
|
|
||||||
|
if (upperVol === 0 && lowerVol === 0) {
|
||||||
|
break; // No more volume to add
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upperVol >= lowerVol) {
|
||||||
|
currentValVol += upperVol;
|
||||||
|
vah = upperPrice;
|
||||||
|
upperPrice += actualTickSize;
|
||||||
|
} else {
|
||||||
|
currentValVol += lowerVol;
|
||||||
|
val = lowerPrice;
|
||||||
|
lowerPrice -= actualTickSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw VAH, VAL, and POC Lines across the chart
|
||||||
|
if (dayPocPrice !== null) {
|
||||||
|
const pocY = getPriceY(dayPocPrice);
|
||||||
|
const vahY = getPriceY(vah);
|
||||||
|
const valY = getPriceY(val);
|
||||||
|
|
||||||
|
// VAH Line
|
||||||
|
ctx.strokeStyle = 'rgba(148, 163, 184, 0.5)'; // Slate-400 dashed
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, vahY); ctx.lineTo(width, vahY); ctx.stroke();
|
||||||
|
|
||||||
|
// VAL Line
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, valY); ctx.lineTo(width, valY); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// POC Line
|
||||||
|
ctx.strokeStyle = 'rgba(255, 214, 0, 0.6)'; // Gold solid
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, pocY); ctx.lineTo(width, pocY); ctx.stroke();
|
||||||
|
|
||||||
|
// POC Label
|
||||||
|
ctx.fillStyle = '#FFD600';
|
||||||
|
ctx.font = '10px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText('POC', width - axisWidth - 5, pocY - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxProfileVolume > 0) {
|
||||||
|
// Solid background for the panel to cover grid/clusters underneath
|
||||||
|
ctx.fillStyle = 'rgba(15, 20, 30, 0.85)';
|
||||||
|
ctx.fillRect(0, 0, volProfileWidth, chartHeight);
|
||||||
|
|
||||||
|
// Right border of panel
|
||||||
|
ctx.strokeStyle = '#2A364F';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(volProfileWidth, 0);
|
||||||
|
ctx.lineTo(volProfileWidth, chartHeight);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Histogram bars
|
||||||
|
Object.keys(volumeProfile).forEach(pStr => {
|
||||||
|
const price = parseFloat(pStr);
|
||||||
|
const vol = volumeProfile[pStr];
|
||||||
|
const y = getPriceY(price) - rowHeight / 2;
|
||||||
|
const barWidth = (vol / maxProfileVolume) * (volProfileWidth - 5);
|
||||||
|
|
||||||
|
// Highlight bars inside Value Area
|
||||||
|
if (price <= vah && price >= val) {
|
||||||
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.6)'; // Stronger blue for Value Area
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)'; // Faded blue outside Value Area
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillRect(0, y, barWidth, rowHeight - 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Panel Title
|
||||||
|
ctx.fillStyle = '#94A3B8';
|
||||||
|
ctx.font = '10px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('VOL PROFILE', volProfileWidth / 2, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Vertical Price Axis (Right Side)
|
||||||
|
ctx.fillStyle = 'rgba(11, 14, 20, 0.95)';
|
||||||
|
ctx.fillRect(width - axisWidth, 0, axisWidth, chartHeight);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#1E293B';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(width - axisWidth, 0);
|
||||||
|
ctx.lineTo(width - axisWidth, chartHeight);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Render price tags along the axis
|
||||||
|
ctx.fillStyle = '#94A3B8';
|
||||||
|
ctx.font = '10px JetBrains Mono, monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
// Step every rowHeight
|
||||||
|
const startY = 0;
|
||||||
|
const endY = chartHeight;
|
||||||
|
|
||||||
|
// Draw tick labels on axis
|
||||||
|
for (let y = startY; y < endY; y += rowHeight) {
|
||||||
|
const price = getYPrice(y);
|
||||||
|
// Align price to tickSize
|
||||||
|
const roundedPrice = Math.round(price / tickSize) * tickSize;
|
||||||
|
const labelY = getPriceY(roundedPrice);
|
||||||
|
|
||||||
|
// Draw grid line connection to axis
|
||||||
|
ctx.strokeStyle = 'rgba(30, 41, 59, 0.5)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, labelY);
|
||||||
|
ctx.lineTo(width - axisWidth, labelY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2;
|
||||||
|
ctx.fillStyle = '#64748B';
|
||||||
|
ctx.fillText(`${roundedPrice.toFixed(decimals)}`, width - axisWidth + 8, labelY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom Panel separator
|
||||||
|
const panelY = height - bottomPanelHeight;
|
||||||
|
ctx.fillStyle = '#111827';
|
||||||
|
ctx.fillRect(0, panelY, width, bottomPanelHeight);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#1E293B';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, panelY);
|
||||||
|
ctx.lineTo(width, panelY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw CVD zero line
|
||||||
|
const cvdBaseline = panelY + bottomPanelHeight / 2;
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, cvdBaseline);
|
||||||
|
ctx.lineTo(width, cvdBaseline);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// CVD Label
|
||||||
|
ctx.fillStyle = '#94A3B8';
|
||||||
|
ctx.font = '12px Outfit, sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText('CVD / Delta', width - 20, panelY + 20);
|
||||||
|
|
||||||
|
}, [clusters, scrollOffset]);
|
||||||
|
|
||||||
|
// Mouse Interaction: Panning/Scrolling
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
setIsDragging(true);
|
||||||
|
dragStart.current = { x, y };
|
||||||
|
dragOffsetStart.current = { ...scrollOffset };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const dx = x - dragStart.current.x;
|
||||||
|
const dy = y - dragStart.current.y;
|
||||||
|
|
||||||
|
setScrollOffset({
|
||||||
|
x: dragOffsetStart.current.x + dx,
|
||||||
|
y: dragOffsetStart.current.y + dy
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wheel interaction for scrolling and zooming
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
// Zoom in/out
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
setZoom(z => Math.min(2.5, z + 0.1));
|
||||||
|
} else {
|
||||||
|
setZoom(z => Math.max(0.3, z - 0.1));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// shift + wheel = horizontal scroll, normal wheel = vertical scroll
|
||||||
|
if (e.shiftKey) {
|
||||||
|
setScrollOffset(prev => ({ ...prev, x: prev.x - e.deltaY }));
|
||||||
|
} else {
|
||||||
|
setScrollOffset(prev => ({ ...prev, y: prev.y - e.deltaY * 0.5 }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full cursor-grab active:cursor-grabbing select-none overflow-hidden rounded-xl border border-slate-800 bg-darkBg shadow-2xl">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full h-full block"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-4 left-4 flex gap-2 items-center bg-darkBg/50 p-2 rounded-xl backdrop-blur-md border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom(z => Math.max(0.3, z - 0.1))}
|
||||||
|
className="w-8 h-8 flex items-center justify-center bg-darkPanel border border-slate-700 rounded-lg text-lg font-bold text-slate-300 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
title="Zoom Out"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono text-slate-400 min-w-[35px] text-center">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom(z => Math.min(2.5, z + 0.1))}
|
||||||
|
className="w-8 h-8 flex items-center justify-center bg-darkPanel border border-slate-700 rounded-lg text-lg font-bold text-slate-300 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-slate-700 mx-1"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setScrollOffset({ x: 50, y: 0 }); setZoom(1); }}
|
||||||
|
className="px-4 py-1.5 bg-darkPanel border border-slate-700 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Reset View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
background-color: #0B0E14;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #0B0E14;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #1C2331;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #2D3748;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export function useWebSocket(url, onMessageCallback) {
|
||||||
|
const [status, setStatus] = useState('disconnected');
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const reconnectTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (!active) return;
|
||||||
|
setStatus('connecting');
|
||||||
|
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (!active) return;
|
||||||
|
setStatus('connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (!active) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
onMessageCallback(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing WS message:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!active) return;
|
||||||
|
setStatus('disconnected');
|
||||||
|
reconnectTimeoutRef.current = setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
console.error("WS connection error:", err);
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (wsRef.current) wsRef.current.close();
|
||||||
|
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, [url, onMessageCallback]);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
class AlertEngine {
|
||||||
|
constructor() {
|
||||||
|
this.processedClusters = new Set();
|
||||||
|
this.audioCache = {};
|
||||||
|
|
||||||
|
// We could load actual mp3/wav files here. For this implementation,
|
||||||
|
// we'll use the browser's SpeechSynthesis API as a fallback to actually say the alert out loud,
|
||||||
|
// which is very useful for trading without looking at the screen.
|
||||||
|
}
|
||||||
|
|
||||||
|
playAudio(message) {
|
||||||
|
if ('speechSynthesis' in window) {
|
||||||
|
const msg = new SpeechSynthesisUtterance(message);
|
||||||
|
msg.rate = 1.2;
|
||||||
|
msg.pitch = 1.1;
|
||||||
|
window.speechSynthesis.speak(msg);
|
||||||
|
} else {
|
||||||
|
console.log('Audio Alert:', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processClusters(clusters, pushToast) {
|
||||||
|
if (!clusters || clusters.length === 0) return;
|
||||||
|
|
||||||
|
clusters.forEach(cluster => {
|
||||||
|
// Only alert on closed clusters that we haven't processed yet
|
||||||
|
if (cluster.status === 'closed' && cluster.open_time && !this.processedClusters.has(cluster.open_time)) {
|
||||||
|
this.processedClusters.add(cluster.open_time);
|
||||||
|
|
||||||
|
const adv = cluster.advanced_metrics;
|
||||||
|
if (!adv) return;
|
||||||
|
|
||||||
|
let alerts = [];
|
||||||
|
|
||||||
|
// 1. P/B Pattern Alerts
|
||||||
|
if (adv.pattern === 'P') {
|
||||||
|
alerts.push({ type: 'info', msg: 'P Pattern Detected (Possible Short Covering)' });
|
||||||
|
} else if (adv.pattern === 'B') {
|
||||||
|
alerts.push({ type: 'info', msg: 'B Pattern Detected (Possible Long Liquidation)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Exhaustion/Absorption Alerts
|
||||||
|
if (adv.top_extreme === 'absorption') {
|
||||||
|
alerts.push({ type: 'warning', msg: 'Heavy Absorption at the Highs!' });
|
||||||
|
} else if (adv.top_extreme === 'exhaustion') {
|
||||||
|
alerts.push({ type: 'info', msg: 'Exhaustion at the Highs.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adv.bottom_extreme === 'absorption') {
|
||||||
|
alerts.push({ type: 'warning', msg: 'Heavy Absorption at the Lows!' });
|
||||||
|
} else if (adv.bottom_extreme === 'exhaustion') {
|
||||||
|
alerts.push({ type: 'info', msg: 'Exhaustion at the Lows.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Divergence Alerts
|
||||||
|
if (adv.delta_divergence) {
|
||||||
|
alerts.push({ type: 'error', msg: 'Delta Divergence! Price moving against Order Flow.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Ratio Extremes
|
||||||
|
if (adv.high_ratio && adv.high_ratio < 0.5) {
|
||||||
|
alerts.push({ type: 'warning', msg: `High Ratio Alert: ${adv.high_ratio}` });
|
||||||
|
}
|
||||||
|
if (adv.low_ratio && adv.low_ratio < 0.5) {
|
||||||
|
alerts.push({ type: 'warning', msg: `Low Ratio Alert: ${adv.low_ratio}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch alerts
|
||||||
|
if (alerts.length > 0) {
|
||||||
|
// Play highest priority audio
|
||||||
|
const hasWarning = alerts.some(a => a.type === 'warning' || a.type === 'error');
|
||||||
|
if (hasWarning) {
|
||||||
|
this.playAudio(alerts[0].msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push UI toasts
|
||||||
|
if (pushToast) {
|
||||||
|
alerts.forEach(a => pushToast(a.msg, a.type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new AlertEngine();
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
darkBg: "#0B0E14",
|
||||||
|
darkPanel: "#151B26",
|
||||||
|
neonGreen: "#00E676",
|
||||||
|
neonRed: "#FF1744",
|
||||||
|
goldPOC: "#FFD600",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn>=0.27.0
|
||||||
|
MetaTrader5>=5.0.33
|
||||||
|
pandas>=2.0.0
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
# Add root folder to path so we can import config and backend
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from backend.aggregator import Aggregator, FootprintCluster, classify_tick
|
||||||
|
|
||||||
|
class TestAggregator(unittest.TestCase):
|
||||||
|
def test_tick_classification(self):
|
||||||
|
# TICK_FLAG_BUY = 0x2
|
||||||
|
# TICK_FLAG_SELL = 0x4
|
||||||
|
|
||||||
|
# 1. With flags
|
||||||
|
self.assertTrue(classify_tick(last=10.0, bid=9.0, ask=11.0, flags=0x2)) # Buy flag
|
||||||
|
self.assertFalse(classify_tick(last=10.0, bid=9.0, ask=11.0, flags=0x4)) # Sell flag
|
||||||
|
# Buy flag should take priority even if last <= bid
|
||||||
|
self.assertTrue(classify_tick(last=8.0, bid=9.0, ask=11.0, flags=0x2))
|
||||||
|
|
||||||
|
# 2. Fallbacks (no flags or flag=0)
|
||||||
|
self.assertTrue(classify_tick(last=11.5, bid=9.0, ask=11.0, flags=0)) # last >= ask
|
||||||
|
self.assertFalse(classify_tick(last=8.5, bid=9.0, ask=11.0, flags=0)) # last <= bid
|
||||||
|
|
||||||
|
# Mid-spread fallbacks
|
||||||
|
self.assertTrue(classify_tick(last=10.5, bid=9.0, ask=11.0, flags=0)) # closer to ask
|
||||||
|
self.assertFalse(classify_tick(last=9.5, bid=9.0, ask=11.0, flags=0)) # closer to bid
|
||||||
|
|
||||||
|
def test_poc_calculation_and_tie(self):
|
||||||
|
cluster = FootprintCluster(tick_size=1.0)
|
||||||
|
|
||||||
|
# Add ticks
|
||||||
|
cluster.add_tick(price=10.0, volume=100.0, is_buy=True, timestamp_msc=1000)
|
||||||
|
cluster.add_tick(price=11.0, volume=150.0, is_buy=False, timestamp_msc=1100)
|
||||||
|
cluster.add_tick(price=12.0, volume=50.0, is_buy=True, timestamp_msc=1200)
|
||||||
|
|
||||||
|
# POC should be 11.0 (highest volume 150)
|
||||||
|
self.assertEqual(cluster.poc, 11.0)
|
||||||
|
|
||||||
|
# Add more volume to 10.0 to create a tie of 150.0 with 11.0
|
||||||
|
cluster.add_tick(price=10.0, volume=50.0, is_buy=True, timestamp_msc=1300)
|
||||||
|
# 10.0 total = 150.0. 11.0 total = 150.0.
|
||||||
|
# Tie breaker rule: highest price level wins, so 11.0 should still be POC
|
||||||
|
self.assertEqual(cluster.poc, 11.0)
|
||||||
|
|
||||||
|
# Now let 12.0 tie with 150.0 (currently 50.0, add 100.0)
|
||||||
|
cluster.add_tick(price=12.0, volume=100.0, is_buy=False, timestamp_msc=1400)
|
||||||
|
# Tie between 10.0, 11.0, and 12.0. Highest price level is 12.0
|
||||||
|
self.assertEqual(cluster.poc, 12.0)
|
||||||
|
|
||||||
|
def test_diagonal_imbalances(self):
|
||||||
|
cluster = FootprintCluster(tick_size=1.0)
|
||||||
|
|
||||||
|
# Let's seed level 10.0 and 11.0
|
||||||
|
# At level 10.0, bid_vol = 10.0
|
||||||
|
# At level 11.0, ask_vol = 30.0
|
||||||
|
# ask_vol[11.0] (30.0) >= 3.0 * bid_vol[10.0] (10.0) -> Buy imbalance at 11.0!
|
||||||
|
cluster.add_tick(price=10.0, volume=10.0, is_buy=False, timestamp_msc=1000)
|
||||||
|
cluster.add_tick(price=11.0, volume=30.0, is_buy=True, timestamp_msc=1010)
|
||||||
|
|
||||||
|
levels_data = cluster.to_json()["levels"]
|
||||||
|
self.assertEqual(levels_data["11.00"]["imbalance"], "buy")
|
||||||
|
|
||||||
|
# At level 12.0, ask_vol = 50.0
|
||||||
|
# At level 11.0, bid_vol = 150.0
|
||||||
|
# bid_vol[11.0] (150.0) >= 3.0 * ask_vol[12.0] (50.0) -> Sell imbalance at 11.0!
|
||||||
|
cluster.add_tick(price=12.0, volume=50.0, is_buy=True, timestamp_msc=1020)
|
||||||
|
cluster.add_tick(price=11.0, volume=150.0, is_buy=False, timestamp_msc=1030)
|
||||||
|
|
||||||
|
levels_data = cluster.to_json()["levels"]
|
||||||
|
self.assertEqual(levels_data["11.00"]["imbalance"], "both")
|
||||||
|
|
||||||
|
def test_stacked_imbalances(self):
|
||||||
|
cluster = FootprintCluster(tick_size=1.0)
|
||||||
|
|
||||||
|
# Seed bid levels
|
||||||
|
cluster.add_tick(price=9.0, volume=10.0, is_buy=False, timestamp_msc=1000)
|
||||||
|
cluster.add_tick(price=10.0, volume=10.0, is_buy=False, timestamp_msc=1000)
|
||||||
|
cluster.add_tick(price=11.0, volume=10.0, is_buy=False, timestamp_msc=1000)
|
||||||
|
|
||||||
|
# Seed ask levels to trigger buy imbalances at 10.0, 11.0, 12.0
|
||||||
|
# ask[10.0] >= 3 * bid[9.0] -> ask[10.0] >= 30
|
||||||
|
cluster.add_tick(price=10.0, volume=30.0, is_buy=True, timestamp_msc=1000)
|
||||||
|
# ask[11.0] >= 3 * bid[10.0] -> ask[11.0] >= 30
|
||||||
|
cluster.add_tick(price=11.0, volume=30.0, is_buy=True, timestamp_msc=1000)
|
||||||
|
# ask[12.0] >= 3 * bid[11.0] -> ask[12.0] >= 30
|
||||||
|
cluster.add_tick(price=12.0, volume=30.0, is_buy=True, timestamp_msc=1000)
|
||||||
|
|
||||||
|
res = cluster.to_json()
|
||||||
|
self.assertTrue(res["stacked"]["buy"])
|
||||||
|
self.assertIn(10.0, res["stacked"]["price_range"])
|
||||||
|
self.assertIn(11.0, res["stacked"]["price_range"])
|
||||||
|
self.assertIn(12.0, res["stacked"]["price_range"])
|
||||||
|
|
||||||
|
def test_closure_criteria(self):
|
||||||
|
# Override settings programmatically
|
||||||
|
settings.CLUSTER_RANGE_POINTS = 10
|
||||||
|
settings.CLUSTER_VOLUME_MAX = 1000
|
||||||
|
settings.CLUSTER_DELTA_MAX = 500
|
||||||
|
settings.CLUSTER_TIME_SECONDS = 60
|
||||||
|
|
||||||
|
agg = Aggregator(tick_size=1.0)
|
||||||
|
|
||||||
|
# 1. Test closure by range
|
||||||
|
active, closed = agg.process_tick(price=100.0, volume=1.0, is_buy=True, timestamp_msc=1000)
|
||||||
|
self.assertIsNone(closed)
|
||||||
|
|
||||||
|
active, closed = agg.process_tick(price=110.0, volume=1.0, is_buy=True, timestamp_msc=1050)
|
||||||
|
self.assertIsNotNone(closed)
|
||||||
|
self.assertEqual(closed["close_reason"], "range")
|
||||||
|
|
||||||
|
# 2. Test closure by volume
|
||||||
|
agg = Aggregator(tick_size=1.0)
|
||||||
|
# Add 400 buy (delta=400, vol=400)
|
||||||
|
active, closed = agg.process_tick(price=100.0, volume=400.0, is_buy=True, timestamp_msc=1000)
|
||||||
|
self.assertIsNone(closed)
|
||||||
|
# Add 400 sell (delta=0, vol=800)
|
||||||
|
active, closed = agg.process_tick(price=100.0, volume=400.0, is_buy=False, timestamp_msc=1010)
|
||||||
|
self.assertIsNone(closed)
|
||||||
|
# Add 200 buy (delta=200, vol=1000) -> Should close due to volume
|
||||||
|
active, closed = agg.process_tick(price=100.0, volume=200.0, is_buy=True, timestamp_msc=1020)
|
||||||
|
self.assertIsNotNone(closed)
|
||||||
|
self.assertEqual(closed["close_reason"], "volume")
|
||||||
|
|
||||||
|
# 3. Test closure by delta
|
||||||
|
agg = Aggregator(tick_size=1.0)
|
||||||
|
active, closed = agg.process_tick(price=100.0, volume=499.0, is_buy=True, timestamp_msc=1000)
|
||||||
|
self.assertIsNone(closed)
|
||||||
|
active, closed = agg.process_tick(price=100.0, volume=1.0, is_buy=True, timestamp_msc=1010)
|
||||||
|
self.assertIsNotNone(closed)
|
||||||
|
self.assertEqual(closed["close_reason"], "delta")
|
||||||
|
|
||||||
|
# 4. Test closure by time
|
||||||
|
agg = Aggregator(tick_size=1.0)
|
||||||
|
active, closed = agg.process_tick(price=100.0, volume=1.0, is_buy=True, timestamp_msc=1000)
|
||||||
|
self.assertIsNone(closed)
|
||||||
|
active, closed = agg.process_tick(price=100.0, volume=1.0, is_buy=True, timestamp_msc=1000 + 60000)
|
||||||
|
self.assertIsNotNone(closed)
|
||||||
|
self.assertEqual(closed["close_reason"], "time")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user