From 0a185c0223678359e61b5ea62c790c1ad37cd87d Mon Sep 17 00:00:00 2001 From: Thiago Moura Date: Thu, 4 Jun 2026 18:12:47 -0300 Subject: [PATCH] Commit inicial: Projeto YuClusters --- .gitignore | 32 + README.md | 67 + SKILL.md | 16 + backend/aggregator.py | 389 +++ backend/mt5_collector.py | 175 ++ backend/server.py | 116 + config/settings.py | 22 + docker-compose.yml | 21 + docker/mt5-gateway/Dockerfile | 40 + docker/mt5-gateway/app.py | 102 + .../mt5-gateway/scripts/05_install_python.sh | 10 + .../scripts/06_install_libraries.sh | 12 + docker/mt5-gateway/scripts/06b_install_mt5.sh | 27 + .../scripts/07_start_wine_flask.sh | 18 + frontend/index.html | 17 + frontend/package-lock.json | 2665 +++++++++++++++++ frontend/package.json | 24 + frontend/postcss.config.js | 6 + frontend/src/App.jsx | 275 ++ frontend/src/FootprintCanvas.jsx | 699 +++++ frontend/src/index.css | 33 + frontend/src/main.jsx | 10 + frontend/src/useWebSocket.js | 55 + frontend/src/utils/AlertEngine.js | 86 + frontend/tailwind.config.js | 19 + frontend/vite.config.js | 10 + requirements.txt | 4 + tests/test_aggregator.py | 143 + 28 files changed, 5093 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 backend/aggregator.py create mode 100644 backend/mt5_collector.py create mode 100644 backend/server.py create mode 100644 config/settings.py create mode 100644 docker-compose.yml create mode 100644 docker/mt5-gateway/Dockerfile create mode 100644 docker/mt5-gateway/app.py create mode 100644 docker/mt5-gateway/scripts/05_install_python.sh create mode 100644 docker/mt5-gateway/scripts/06_install_libraries.sh create mode 100644 docker/mt5-gateway/scripts/06b_install_mt5.sh create mode 100644 docker/mt5-gateway/scripts/07_start_wine_flask.sh create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/FootprintCanvas.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/useWebSocket.js create mode 100644 frontend/src/utils/AlertEngine.js create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 requirements.txt create mode 100644 tests/test_aggregator.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da8eae4 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..535fab5 --- /dev/null +++ b/README.md @@ -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. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..4b9ecbe --- /dev/null +++ b/SKILL.md @@ -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 diff --git a/backend/aggregator.py b/backend/aggregator.py new file mode 100644 index 0000000..a28ba23 --- /dev/null +++ b/backend/aggregator.py @@ -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 + diff --git a/backend/mt5_collector.py b/backend/mt5_collector.py new file mode 100644 index 0000000..6e086a3 --- /dev/null +++ b/backend/mt5_collector.py @@ -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.") diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..b116c94 --- /dev/null +++ b/backend/server.py @@ -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", + ) diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..89c6fa4 --- /dev/null +++ b/config/settings.py @@ -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)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bddde90 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker/mt5-gateway/Dockerfile b/docker/mt5-gateway/Dockerfile new file mode 100644 index 0000000..1f01a76 --- /dev/null +++ b/docker/mt5-gateway/Dockerfile @@ -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"] diff --git a/docker/mt5-gateway/app.py b/docker/mt5-gateway/app.py new file mode 100644 index 0000000..02d43b2 --- /dev/null +++ b/docker/mt5-gateway/app.py @@ -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/', 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) diff --git a/docker/mt5-gateway/scripts/05_install_python.sh b/docker/mt5-gateway/scripts/05_install_python.sh new file mode 100644 index 0000000..70f5785 --- /dev/null +++ b/docker/mt5-gateway/scripts/05_install_python.sh @@ -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." diff --git a/docker/mt5-gateway/scripts/06_install_libraries.sh b/docker/mt5-gateway/scripts/06_install_libraries.sh new file mode 100644 index 0000000..58cc9d3 --- /dev/null +++ b/docker/mt5-gateway/scripts/06_install_libraries.sh @@ -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." diff --git a/docker/mt5-gateway/scripts/06b_install_mt5.sh b/docker/mt5-gateway/scripts/06b_install_mt5.sh new file mode 100644 index 0000000..0d3d720 --- /dev/null +++ b/docker/mt5-gateway/scripts/06b_install_mt5.sh @@ -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." diff --git a/docker/mt5-gateway/scripts/07_start_wine_flask.sh b/docker/mt5-gateway/scripts/07_start_wine_flask.sh new file mode 100644 index 0000000..13aa0da --- /dev/null +++ b/docker/mt5-gateway/scripts/07_start_wine_flask.sh @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a296817 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + YuClusters Local — Pro Footprint Chart + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..101d2d2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2665 @@ +{ + "name": "yuclusters-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yuclusters-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", + "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.367", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz", + "integrity": "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6c78bef --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a802ea3 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+ {/* Toast Container */} +
+ {toasts.map(t => ( +
+ {t.type === 'error' ? '🚨' : t.type === 'warning' ? '⚠️' : 'ℹ️'} + {t.msg} +
+ ))} +
+ + {/* Premium Header */} +
+
+
+ Yu +
+
+

+ YuClusters Local +

+

Order Flow Footprint Analyzer 2.0

+
+
+ + {/* Connection Status indicator */} +
+ {lastTickTime && ( +
+ Last Update: {lastTickTime.toLocaleTimeString()} +
+ )} + +
+ MT5 Bridge: +
+ + {wsStatus.toUpperCase()} +
+
+
+
+ + {/* Main Body Layout */} +
+ {/* Footprint Chart Panel */} +
+ +
+ + {/* Info Sidebar Panel */} + +
+
+ ); +} diff --git a/frontend/src/FootprintCanvas.jsx b/frontend/src/FootprintCanvas.jsx new file mode 100644 index 0000000..6432ae5 --- /dev/null +++ b/frontend/src/FootprintCanvas.jsx @@ -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 ( +
+ +
+ + + {Math.round(zoom * 100)}% + + + +
+ + +
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..068e2f9 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + , +) diff --git a/frontend/src/useWebSocket.js b/frontend/src/useWebSocket.js new file mode 100644 index 0000000..d0cbc6c --- /dev/null +++ b/frontend/src/useWebSocket.js @@ -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; +} diff --git a/frontend/src/utils/AlertEngine.js b/frontend/src/utils/AlertEngine.js new file mode 100644 index 0000000..9929208 --- /dev/null +++ b/frontend/src/utils/AlertEngine.js @@ -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(); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..3a2d996 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..bfc2f1b --- /dev/null +++ b/frontend/vite.config.js @@ -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, + } +}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6fe5bcd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.109.0 +uvicorn>=0.27.0 +MetaTrader5>=5.0.33 +pandas>=2.0.0 diff --git a/tests/test_aggregator.py b/tests/test_aggregator.py new file mode 100644 index 0000000..6fa7064 --- /dev/null +++ b/tests/test_aggregator.py @@ -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()