From 5ad08fc40c55d0878aebc6608686024a360549a8 Mon Sep 17 00:00:00 2001 From: QWERTY Date: Tue, 19 May 2026 15:23:56 +0300 Subject: [PATCH 01/16] Improve Windows and Qlib Docker integration for local fin_* runs. Harden LocalEnv/Docker paths, health_check embedding routing, and optional conda-less factor execution so RD-Agent can bootstrap Qlib backtests on Windows hosts. Co-authored-by: Cursor --- .env.example | 13 ++ rdagent/app/utils/health_check.py | 34 +++- .../components/coder/factor_coder/config.py | 10 +- rdagent/components/coder/model_coder/conf.py | 4 +- rdagent/oai/backend/litellm.py | 17 ++ rdagent/scenarios/shared/get_runtime_info.py | 9 +- rdagent/utils/env.py | 162 +++++++++++++----- 7 files changed, 194 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index d11e7eae3..752dc3694 100644 --- a/.env.example +++ b/.env.example @@ -58,4 +58,17 @@ EMBEDDING_MODEL="litellm_proxy/BAAI/bge-large-en-v1.5" # FT_DOCKER_ENABLE_CACHE=True # DS_DOCKER_ENABLE_CACHE=True # Senario Configs: +# ========================================== + +# ========================================== +# Qlib + Docker (finance quant scenarios) +# ========================================== +# Run Qlib backtests inside the RD-Agent Qlib image instead of conda: +# MODEL_CoSTEER_env_type=docker +# +# Optional: use your local Qlib clone on the host (directory that contains setup.py), e.g. Desktop\QLIB\qlib. +# It is mounted at /workspace/qlib in the container (editable install in the image then sees your sources). +# QLIB_DOCKER_local_qlib_repo_path=C:\Users\QWERTY\Desktop\QLIB\qlib +# +# Qlib data cache is still shared via ~/.qlib -> /root/.qlib in the container (download once on the host). # ========================================== \ No newline at end of file diff --git a/rdagent/app/utils/health_check.py b/rdagent/app/utils/health_check.py index 95597b257..1d22cc4f9 100644 --- a/rdagent/app/utils/health_check.py +++ b/rdagent/app/utils/health_check.py @@ -78,11 +78,17 @@ def test_chat(chat_model, chat_api_key, chat_api_base): def test_embedding(embedding_model, embedding_api_key, embedding_api_base): logger.info(f"🧪 Testing embedding model: {embedding_model}") try: + model = embedding_model + extra: dict = {} + if model.startswith("litellm_proxy/") and embedding_api_base and embedding_api_key: + model = model.split("litellm_proxy/", 1)[1] + extra["custom_llm_provider"] = "openai" response = embedding( - model=embedding_model, + model=model, api_key=embedding_api_key, api_base=embedding_api_base, input="Hello world!", + **extra, ) logger.info("✅ Embedding test passed.") return True @@ -99,17 +105,26 @@ def env_check(): ) if "DEEPSEEK_API_KEY" in os.environ: - chat_api_key = os.getenv("DEEPSEEK_API_KEY") chat_model = os.getenv("CHAT_MODEL") embedding_model = os.getenv("EMBEDDING_MODEL") - embedding_api_key = os.getenv("LITELLM_PROXY_API_KEY") - embedding_api_base = os.getenv("LITELLM_PROXY_API_BASE") - if "DEEPSEEK_API_BASE" in os.environ: - chat_api_base = os.getenv("DEEPSEEK_API_BASE") - elif "OPENAI_API_BASE" in os.environ: - chat_api_base = os.getenv("OPENAI_API_BASE") + emb = embedding_model or "" + if emb.startswith("litellm_proxy/"): + embedding_api_key = os.getenv("LITELLM_PROXY_API_KEY") + embedding_api_base = os.getenv("LITELLM_PROXY_API_BASE") else: - chat_api_base = None + embedding_api_key = os.getenv("OPENAI_API_KEY") + embedding_api_base = os.getenv("OPENAI_API_BASE") or "https://api.openai.com/v1" + if chat_model and chat_model.startswith("openrouter/"): + chat_api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") + chat_api_base = os.getenv("OPENROUTER_API_BASE") or os.getenv("OPENAI_API_BASE") + else: + chat_api_key = os.getenv("DEEPSEEK_API_KEY") + if "DEEPSEEK_API_BASE" in os.environ: + chat_api_base = os.getenv("DEEPSEEK_API_BASE") + elif "OPENAI_API_BASE" in os.environ: + chat_api_base = os.getenv("OPENAI_API_BASE") + else: + chat_api_base = None elif "OPENAI_API_KEY" in os.environ: chat_api_key = os.getenv("OPENAI_API_KEY") chat_api_base = os.getenv("OPENAI_API_BASE") @@ -119,6 +134,7 @@ def env_check(): embedding_api_base = chat_api_base else: logger.error("No valid configuration was found, please check your .env file.") + return logger.info("🚀 Starting test...\n") result_embedding = test_embedding( diff --git a/rdagent/components/coder/factor_coder/config.py b/rdagent/components/coder/factor_coder/config.py index a09ae666a..8ecb246db 100644 --- a/rdagent/components/coder/factor_coder/config.py +++ b/rdagent/components/coder/factor_coder/config.py @@ -1,10 +1,11 @@ import os +import shutil from typing import Optional from pydantic_settings import SettingsConfigDict from rdagent.components.coder.CoSTEER.config import CoSTEERSettings -from rdagent.utils.env import CondaConf, Env, LocalEnv +from rdagent.utils.env import CondaConf, Env, LocalConf, LocalEnv class FactorCoSTEERSettings(CoSTEERSettings): @@ -37,7 +38,12 @@ def get_factor_env( ) -> Env: conf = FactorCoSTEERSettings() if hasattr(conf, "python_bin"): - env = LocalEnv(conf=(CondaConf(conda_env_name=os.environ.get("CONDA_DEFAULT_ENV")))) + if shutil.which("conda"): + env = LocalEnv( + conf=(CondaConf(conda_env_name=os.environ.get("CONDA_DEFAULT_ENV") or "base")), + ) + else: + env = LocalEnv(conf=LocalConf(default_entry="python main.py")) env.conf.extra_volumes = extra_volumes.copy() env.conf.running_timeout_period = running_timeout_period if enable_cache is not None: diff --git a/rdagent/components/coder/model_coder/conf.py b/rdagent/components/coder/model_coder/conf.py index 1cb8d2d35..1c05e56fb 100644 --- a/rdagent/components/coder/model_coder/conf.py +++ b/rdagent/components/coder/model_coder/conf.py @@ -27,7 +27,9 @@ def get_model_env( else: raise ValueError(f"Unknown env type: {conf.env_type}") - env.conf.extra_volumes = extra_volumes.copy() + base_vols = dict(getattr(env.conf, "extra_volumes", None) or {}) + base_vols.update(extra_volumes) + env.conf.extra_volumes = base_vols env.conf.running_timeout_period = running_timeout_period if enable_cache is not None: env.conf.enable_cache = enable_cache diff --git a/rdagent/oai/backend/litellm.py b/rdagent/oai/backend/litellm.py index 86cbe8461..5ce66dead 100644 --- a/rdagent/oai/backend/litellm.py +++ b/rdagent/oai/backend/litellm.py @@ -1,4 +1,5 @@ import copyreg +import os from typing import Any, Literal, Optional, Type, TypedDict, Union, cast import numpy as np @@ -79,9 +80,25 @@ def _create_embedding_inner_function(self, input_content_list: list[str]) -> lis f"{LogColors.MAGENTA}Creating embedding{LogColors.END} for: {input_content_list}", tag="debug_litellm_emb", ) + # SiliconFlow litellm_proxy workaround (invalid upstream params). + emb_kwargs: dict[str, Any] = {} + proxy_base = os.getenv("LITELLM_PROXY_API_BASE") + proxy_key = os.getenv("LITELLM_PROXY_API_KEY") + if model_name.startswith("litellm_proxy/") and proxy_base and proxy_key: + model_name = model_name.split("litellm_proxy/", 1)[1] + emb_kwargs["api_base"] = proxy_base + emb_kwargs["api_key"] = proxy_key + emb_kwargs["custom_llm_provider"] = "openai" + elif model_name.startswith("openrouter/") and not emb_kwargs: + or_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") + or_base = os.getenv("OPENROUTER_API_BASE") or os.getenv("OPENAI_API_BASE") + if or_key and or_base: + emb_kwargs["api_key"] = or_key + emb_kwargs["api_base"] = or_base response = embedding( model=model_name, input=input_content_list, + **emb_kwargs, ) response_list = [data["embedding"] for data in response.data] return response_list diff --git a/rdagent/scenarios/shared/get_runtime_info.py b/rdagent/scenarios/shared/get_runtime_info.py index 9e66c7d3d..53060ddbb 100644 --- a/rdagent/scenarios/shared/get_runtime_info.py +++ b/rdagent/scenarios/shared/get_runtime_info.py @@ -1,5 +1,7 @@ import json +import os import re +import sys from pathlib import Path from rdagent.core.experiment import FBWorkspace @@ -10,9 +12,14 @@ def get_runtime_environment_by_env(env: Env) -> str: implementation = FBWorkspace() fname = "runtime_info.py" implementation.inject_files(**{fname: (Path(__file__).absolute().resolve().parent / "runtime_info.py").read_text()}) - stdout = implementation.execute(env=env, entry=f"python {fname}") + py = f'"{sys.executable}"' if os.name == "nt" else "python" + stdout = implementation.execute(env=env, entry=f"{py} {fname}") # Extract JSON from stdout (skip CUDA/container warnings) json_match = re.search(r"\{.*\}", stdout, re.DOTALL) + if json_match is None: + raise RuntimeError( + f"runtime_info.py did not print JSON. stdout (truncated): {stdout[:2000]!r}", + ) return json.dumps(json.loads(json_match.group()), indent=2) diff --git a/rdagent/utils/env.py b/rdagent/utils/env.py index 47d5aea2b..10ac4cfd8 100644 --- a/rdagent/utils/env.py +++ b/rdagent/utils/env.py @@ -11,10 +11,12 @@ import json import os import pickle +import posixpath import re import select import shutil import subprocess +import tempfile import time import uuid import zipfile @@ -42,7 +44,7 @@ import docker.models # type: ignore[import-untyped] import docker.models.containers # type: ignore[import-untyped] import docker.types # type: ignore[import-untyped] -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, Field, model_validator from pydantic_settings import SettingsConfigDict from rich import print from rich.console import Console @@ -116,9 +118,14 @@ def cleanup_container(container: docker.models.containers.Container | None, cont # Normalize all bind paths in volumes to absolute paths using the workspace (working_dir). def normalize_volumes(vols: dict[str, str | dict[str, str]], working_dir: str) -> dict: abs_vols: dict[str, str | dict[str, str]] = {} - - def to_abs(path: str) -> str: - # Converts a relative path to an absolute path using the workspace (working_dir). + linux_container_workspace = working_dir.startswith("/") + + def to_abs_bind(path: str) -> str: + # Linux containers use POSIX paths; os.path on Windows turns "/foo" into "C:\foo" and breaks Docker bind parsing. + if linux_container_workspace: + if path.startswith("/"): + return posixpath.normpath(path) + return posixpath.normpath(posixpath.join(working_dir.rstrip("/"), path)) return os.path.abspath(os.path.join(working_dir, path)) if not os.path.isabs(path) else path for lp, vinfo in vols.items(): @@ -128,14 +135,25 @@ def to_abs(path: str) -> str: if isinstance(vinfo, dict): # abs_vols = cast(dict[str, dict[str, str]], abs_vols) vinfo = vinfo.copy() - vinfo["bind"] = to_abs(vinfo["bind"]) + vinfo["bind"] = to_abs_bind(vinfo["bind"]) abs_vols[lp] = vinfo else: # abs_vols = cast(dict[str, str], abs_vols) - abs_vols[lp] = to_abs(vinfo) + abs_vols[lp] = to_abs_bind(vinfo) return abs_vols +def _docker_workspace_cache_host_path(*, sample: bool) -> str: + """Directory on the host bind-mounted as the data-science workspace cache inside Docker. + + Using ``/tmp/...`` as the host source breaks Docker Desktop on Windows (invalid bind source / colon parsing). + """ + sub = "sample" if sample else "full" + path = Path(tempfile.gettempdir()) / "rdagent_docker_workspace_cache" / sub + path.mkdir(parents=True, exist_ok=True) + return str(path.resolve()) + + def pull_image_with_progress(image: str) -> None: client = docker.APIClient(base_url="unix://var/run/docker.sock") pull_logs = client.pull(image, stream=True, decode=True) @@ -423,22 +441,29 @@ def _get_chmod_cmd(workspace_path: str) -> str: if self.conf.running_timeout_period is None: timeout_cmd = entry - else: + elif isinstance(self.conf, DockerConf) or os.name != "nt": timeout_cmd = f"timeout --kill-after=10 {self.conf.running_timeout_period} {entry}" - entry_add_timeout = ( - f"/bin/sh -c '" # start of the sh command - + f"{timeout_cmd}; entry_exit_code=$?; " - + ( - f"{_get_chmod_cmd(self.conf.mount_path)}; " - # We don't have to change the permission of the cache and input folder to remove it - # + f"if [ -d {self.conf.mount_path}/cache ]; then chmod 777 {self.conf.mount_path}/cache; fi; " + - # f"if [ -d {self.conf.mount_path}/input ]; then chmod 777 {self.conf.mount_path}/input; fi; " - if isinstance(self.conf, DockerConf) - else "" + else: + # Windows has no GNU `timeout` in the same form; run entry directly (LocalEnv only). + timeout_cmd = entry + + if isinstance(self.conf, DockerConf): + entry_add_timeout = ( + f"/bin/sh -c '" # start of the sh command + + f"{timeout_cmd}; entry_exit_code=$?; " + + f"{_get_chmod_cmd(self.conf.mount_path)}; " + + "exit $entry_exit_code" + + "'" # end of the sh command + ) + elif os.name == "nt": + entry_add_timeout = timeout_cmd + else: + entry_add_timeout = ( + f"/bin/sh -c '" # start of the sh command + + f"{timeout_cmd}; entry_exit_code=$?; " + + "exit $entry_exit_code" + + "'" # end of the sh command ) - + "exit $entry_exit_code" - + "'" # end of the sh command - ) if self.conf.enable_cache: result = self.cached_run( @@ -604,8 +629,8 @@ def _run( if self.conf.extra_volumes is not None: for lp, rp in self.conf.extra_volumes.items(): volumes[lp] = rp["bind"] if isinstance(rp, dict) else rp - cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full" - Path(cache_path).mkdir(parents=True, exist_ok=True) + sample_cache = "/sample/" in "".join(self.conf.extra_volumes.keys()) + cache_path = _docker_workspace_cache_host_path(sample=sample_cache) volumes[cache_path] = T("scenarios.data_science.share:scen.cache_path").r() for lp, rp in running_extra_volume.items(): volumes[lp] = rp @@ -640,17 +665,25 @@ def _symlink_ctx(vol_map: Mapping[str, str]) -> Generator[None, None, None]: if env is None: env = {} + merged_preview = {**os.environ, **env} + # Auto-propagate CUDA_VISIBLE_DEVICES for proper GPU isolation if "CUDA_VISIBLE_DEVICES" in os.environ and "CUDA_VISIBLE_DEVICES" not in env: env["CUDA_VISIBLE_DEVICES"] = os.environ["CUDA_VISIBLE_DEVICES"] - path = [ - *self.conf.bin_path.split(":"), - "/bin/", - "/usr/bin/", - *env.get("PATH", "").split(":"), - ] - env["PATH"] = ":".join(path) + _bin = self.conf.bin_path or "" + if os.name == "nt": + extra_bins = [p for p in re.split(r"[;:]", _bin) if p] + path_parts = extra_bins + [p for p in (merged_preview.get("PATH", "") or "").split(os.pathsep) if p] + env["PATH"] = os.pathsep.join(path_parts) + else: + path = [ + *_bin.split(":"), + "/bin/", + "/usr/bin/", + *merged_preview.get("PATH", "").split(":"), + ] + env["PATH"] = ":".join(path) if entry is None: entry = self.conf.default_entry @@ -675,6 +708,8 @@ def _symlink_ctx(vol_map: Mapping[str, str]) -> Generator[None, None, None]: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + errors="replace", shell=True, bufsize=1, universal_newlines=True, @@ -684,7 +719,8 @@ def _symlink_ctx(vol_map: Mapping[str, str]) -> Generator[None, None, None]: if process.stdout is None or process.stderr is None: raise RuntimeError("The subprocess did not correctly create stdout/stderr pipes") - if self.conf.live_output: + # Windows CPython has no select.poll(); use batched read via communicate(). + if self.conf.live_output and hasattr(select, "poll"): stdout_fd = process.stdout.fileno() stderr_fd = process.stderr.fileno() @@ -725,9 +761,9 @@ def _symlink_ctx(vol_map: Mapping[str, str]) -> Generator[None, None, None]: else: # Sacrifice real-time output to avoid possible standard I/O hangs out, err = process.communicate() - Console().print(out, end="", markup=False) - Console().print(err, end="", markup=False) - combined_output = out + err + Console().print(out or "", end="", markup=False) + Console().print(err or "", end="", markup=False) + combined_output = (out or "") + (err or "") return_code = process.returncode print(Rule("[bold green]LocalEnv Logs End[/bold green]", style="dark_orange")) @@ -751,12 +787,20 @@ def _update_bin_path(self) -> None: to ensure bin_path is set correctly even if the conda env was just created. """ conda_path_result = subprocess.run( - f"conda run -n {self.conda_env_name} --no-capture-output env | grep '^PATH='", + ["conda", "run", "-n", self.conda_env_name, "--no-capture-output", "env"], capture_output=True, text=True, - shell=True, + shell=False, + encoding="utf-8", + errors="replace", ) - self.bin_path = conda_path_result.stdout.strip().split("=")[1] if conda_path_result.returncode == 0 else "" + self.bin_path = "" + if conda_path_result.returncode != 0: + return + for line in conda_path_result.stdout.splitlines(): + if line.startswith("PATH="): + self.bin_path = line.split("=", 1)[1].strip() + break class MLECondaConf(CondaConf): @@ -1023,11 +1067,31 @@ class QlibDockerConf(DockerConf): "mode": "rw", } } + local_qlib_repo_path: str | None = Field( + default=None, + description=( + "Host path to your Qlib repo (directory with setup.py), mounted at /workspace/qlib. " + "Replaces the image's pre-built Qlib; the mount must contain Linux-built C extensions " + "(e.g. run `pip install -e /workspace/qlib` inside the container), otherwise " + "`qlib.data._libs` imports fail. Omit for the default Qlib from the image. " + "Env: QLIB_DOCKER_local_qlib_repo_path." + ), + ) shm_size: str | None = "16g" enable_gpu: bool = True enable_cache: bool = False save_logs_to_file: bool = True # Explicitly inherit from DockerConf for compatibility + @model_validator(mode="after") + def _mount_local_qlib_repo(self) -> "QlibDockerConf": + if not self.local_qlib_repo_path: + return self + host_path = str(Path(self.local_qlib_repo_path).expanduser().resolve(strict=False)) + merged = dict(self.extra_volumes) + merged[host_path] = {"bind": "/workspace/qlib", "mode": "rw"} + self.extra_volumes = merged + return self + class KGDockerConf(DockerConf): model_config = SettingsConfigDict(env_prefix="KG_DOCKER_") @@ -1432,8 +1496,8 @@ def _run( if self.conf.extra_volumes is not None: for lp, rp in self.conf.extra_volumes.items(): volumes[lp] = rp if isinstance(rp, dict) else {"bind": rp, "mode": self.conf.extra_volume_mode} - cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full" - Path(cache_path).mkdir(parents=True, exist_ok=True) + sample_cache = "/sample/" in "".join(self.conf.extra_volumes.keys()) + cache_path = _docker_workspace_cache_host_path(sample=sample_cache) volumes[cache_path] = { "bind": T("scenarios.data_science.share:scen.cache_path").r(), "mode": "rw", @@ -1511,16 +1575,30 @@ def refresh_env(self) -> None: class QTDockerEnv(DockerEnv): """Qlib Torch Docker""" - def __init__(self, conf: DockerConf = QlibDockerConf()): - super().__init__(conf) + def __init__(self, conf: DockerConf | None = None): + super().__init__(conf if conf is not None else QlibDockerConf()) + + @staticmethod + def _host_path_for_bind_root_qlib(extra_volumes: dict | None) -> Path | None: + if not extra_volumes: + return None + for host_path, spec in extra_volumes.items(): + bind = spec.get("bind", spec) if isinstance(spec, dict) else spec + bind_norm = str(bind).rstrip("/") + if bind_norm == "/root/.qlib": + return Path(host_path) + return Path(next(iter(extra_volumes.keys()))) def prepare(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] """ Download image & data if it doesn't exist """ super().prepare() - qlib_data_path = next(iter(self.conf.extra_volumes.keys())) - if not (Path(qlib_data_path) / "qlib_data" / "cn_data").exists(): + qlib_data_path = self._host_path_for_bind_root_qlib(self.conf.extra_volumes) + if qlib_data_path is None: + logger.warning("Qlib Docker: extra_volumes empty; skipping bundled data check/download.") + return + if not (qlib_data_path / "qlib_data" / "cn_data").exists(): logger.info("We are downloading!") cmd = "python -m qlib.run.get_data qlib_data --target_dir ~/.qlib/qlib_data/cn_data --region cn --interval 1d --delete_old False" self.check_output(entry=cmd) From aa42a7b0f68e7d8d5167dce3d2c8dae6305a60d2 Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:25:54 +0300 Subject: [PATCH 02/16] checkpoint: save return point before terminal PR #1 Windows streamlit launcher fix (python -m streamlit). Safe rollback point before React terminal, FastAPI gateway, and Bybit klines integration. Co-authored-by: Cursor --- rdagent/app/cli.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/rdagent/app/cli.py b/rdagent/app/cli.py index 079371728..e10f7d528 100644 --- a/rdagent/app/cli.py +++ b/rdagent/app/cli.py @@ -48,11 +48,11 @@ def ui(port=19899, log_dir="", debug: bool = False, data_science: bool = False): """ if data_science: with rpath("rdagent.log.ui", "dsapp.py") as app_path: - cmds = ["streamlit", "run", app_path, f"--server.port={port}"] + cmds = [sys.executable, "-m", "streamlit", "run", str(app_path), f"--server.port={port}"] subprocess.run(cmds) return with rpath("rdagent.log.ui", "app.py") as app_path: - cmds = ["streamlit", "run", app_path, f"--server.port={port}"] + cmds = [sys.executable, "-m", "streamlit", "run", str(app_path), f"--server.port={port}"] if log_dir or debug: cmds.append("--") if log_dir: @@ -75,7 +75,14 @@ def ds_user_interact(port=19900): """ start web app to show the log traces in real time """ - commands = ["streamlit", "run", "rdagent/log/ui/ds_user_interact.py", f"--server.port={port}"] + commands = [ + sys.executable, + "-m", + "streamlit", + "run", + "rdagent/log/ui/ds_user_interact.py", + f"--server.port={port}", + ] subprocess.run(commands) From 10f82d85cb6c51afb3b45eb9d075bc0001d6a485 Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:26:47 +0300 Subject: [PATCH 03/16] docs(terminal): add architecture analysis and PR #1 implementation plan Comprehensive plan for React terminal, FastAPI gateway, Bybit klines, and Lightweight Charts. Documents product decisions Q1-Q6, as-is/to-be architecture, API spec, epics, tasks, acceptance criteria, and rollback to checkpoint aa42a7b0. Co-authored-by: Cursor --- docs/terminal/IMPLEMENTATION_PLAN.md | 1474 ++++++++++++++++++++++++++ 1 file changed, 1474 insertions(+) create mode 100644 docs/terminal/IMPLEMENTATION_PLAN.md diff --git a/docs/terminal/IMPLEMENTATION_PLAN.md b/docs/terminal/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..2303b7aa3 --- /dev/null +++ b/docs/terminal/IMPLEMENTATION_PLAN.md @@ -0,0 +1,1474 @@ +# RD-Agent Terminal — Архитектурный анализ и план реализации + +> **Версия документа:** 1.0 +> **Дата:** 2026-05-23 +> **Статус:** Утверждён к реализации PR #1 +> **Git checkpoint (точка возврата):** `aa42a7b0` — `checkpoint: save return point before terminal PR #1` + +--- + +## Содержание + +1. [Executive Summary](#1-executive-summary) +2. [Продуктовые решения (Q1–Q6)](#2-продуктовые-решения-q1q6) +3. [Анализ текущего состояния (As-Is)](#3-анализ-текущего-состояния-as-is) +4. [Целевая архитектура (To-Be)](#4-целевая-архитектура-to-be) +5. [Технологический стек](#5-технологический-стек) +6. [Слой данных (Data Layer)](#6-слой-данных-data-layer) +7. [Интеграция с внешними терминалами и сервисами](#7-интеграция-с-внешними-терминалами-и-сервисами) +8. [GitHub-экосистема: анализ и shortlist](#8-github-экосистема-анализ-и-shortlist) +9. [Best Practices Matrix](#9-best-practices-matrix) +10. [Risk Management Layer](#10-risk-management-layer) +11. [Структура репозитория](#11-структура-репозитория) +12. [API Specification](#12-api-specification) +13. [Roadmap: Phase 1–4](#13-roadmap-phase-14) +14. [PR #1 — Доскональный план реализации](#14-pr-1--доскональный-план-реализации) +15. [Local Deployment (Windows + WSL2)](#15-local-deployment-windows--wsl2) +16. [Критерии приёмки и Definition of Done](#16-критерии-приёмки-и-definition-of-done) +17. [Rollback и управление версиями](#17-rollback-и-управление-версиями) +18. [Открытые вопросы и решения по умолчанию](#18-открытые-вопросы-и-решения-по-умолчанию) +19. [Приложения](#19-приложения) + +--- + +## 1. Executive Summary + +### 1.1. Контекст + +**RD-Agent** (Microsoft) — LLM-driven R&D automation framework для quant finance, Kaggle, paper-to-model и смежных сценариев. Execution engine для финансовых quant-сценариев — **Microsoft Qlib**. Текущий UI фрагментирован: Vue 3 SPA + Flask (`server_ui`) для agent loop и Streamlit для глубокой qlib-аналитики. + +### 1.2. Стратегическая цель + +Построить **RD-Agent Terminal** — профессиональный full-stack trading/research terminal, объединяющий: + +| Зона | Назначение | +|------|------------| +| **Agent Console** | LLM R&D loop: hypothesis → code → run → feedback | +| **Research Lab** | qlib metrics: IC, returns, drawdown, factor analysis | +| **Execution Monitor** | Live/paper trading, positions, orders, P&L, risk | + +### 1.3. Ключевые решения заказчика + +| Вопрос | Решение | Интерпретация | +|--------|---------|---------------| +| Q1 | **C** | Full terminal: research + live execution + risk management | +| Q2 | **C** (Bybit first) | Multi-market; Phase 1 execution = **Bybit crypto** | +| Q3 | **B** | Migrate to **React terminal-style** UI | +| Q4 | **A** | **TradingView Lightweight Charts** (Apache-2.0) | +| Q5 | **A** | Local self-hosted (Windows dev + WSL2/Docker для qlib) | +| Q6 | **A** (Tiger) | Equities execution в **Phase 4**, после Bybit MVP | + +### 1.4. Scope PR #1 + +PR #1 — foundation layer без agent bridge и без order placement: + +- Scaffold `terminal/` — React 19 + TypeScript + Vite +- Scaffold `gateway/` — FastAPI API gateway +- **BybitAdapter** — read-only market data (klines, symbols, ticker) +- **Lightweight Charts** — OHLC panel с live/historical данными +- Workspace shell (react-grid-layout) +- `docker-compose.terminal.yml` + документация dev setup + +### 1.5. Принципиальное ограничение + +**Qlib** оптимизирован под equities (CSI300 и т.д.). **Bybit** — crypto perpetuals/spot. На Phase 1 это **два параллельных track**: + +- **Quant R&D track** — существующий RD-Agent + qlib (без изменений в PR #1) +- **Market/Execution track** — Bybit data + chart (PR #1) + +Связка signal → order — Phase 3. + +--- + +## 2. Продуктовые решения (Q1–Q6) + +### 2.1. Q1 = C — Full Terminal + +#### 2.1.1. Что включает +- Agent orchestration UI (RD loop live) +- Research analytics (qlib backtest results) +- Execution monitor (orders, positions) +- Risk management layer (kill switch, limits) + +#### 2.1.2. Что исключает на Phase 1 +- Live order placement (Phase 3) +- Automated signal-to-order без manual approval (Phase 3+) + +#### 2.1.3. Последствия для архитектуры +- Обязателен **Broker Adapter Pattern** с самого начала +- Risk controls проектируются до первого live order +- Audit trail для всех execution events + +### 2.2. Q2 = C — Multi-market, Bybit first + +#### 2.2.1. Phase 1 — Bybit (crypto) +- Products: **Linear USDT Perpetuals** (BTCUSDT, ETHUSDT, …) +- Environment: **testnet default** +- SDK: [pybit](https://github.com/bybit-exchange/pybit) v5 unified trading + +#### 2.2.2. Phase 2–3 — Research data expansion +- qlib CN equities (existing) +- Bybit klines → parquet cache для crypto backtest + +#### 2.2.3. Phase 4 — Multi-broker +- **Tiger Trade** — US/HK/CN equities (tigeropen SDK) +- Optional: Alpaca, Interactive Brokers + +#### 2.2.4. Разрешение конфликта Q6 vs Q2 +Q6 (Tiger) откладывается до Phase 4. Execution priority: **Bybit → Paper → Tiger**. + +### 2.3. Q3 = B — React Terminal Migration + +#### 2.3.1. Новый primary UI +- Директория `terminal/` — React 19 + TypeScript + Vite +- Terminal-style UX: command bar, workspace panels, status bar + +#### 2.3.2. Legacy UI +- `web/` (Vue 3) — **deprecated**, не удалять до Phase 2 completion +- Streamlit — debug/internal only после Phase 2 + +#### 2.3.3. Migration strategy +- Parallel run: Vue + React coexist Phase 1–2 +- Feature parity Agent Console — Phase 2 +- Vue removal — Phase 2 end + +### 2.4. Q4 = A — Lightweight Charts + +#### 2.4.1. Выбрано +- [tradingview/lightweight-charts](https://github.com/tradingview/lightweight-charts) v5.x +- License: Apache-2.0, бесплатно + +#### 2.4.2. Не выбрано +- TradingView Charting Library (commercial, ~$3k+/year) +- TradingView embed widget (ограничен, не для custom signals) + +#### 2.4.3. Разделение charting +| Тип данных | Библиотека | +|------------|------------| +| OHLC, volume, signal markers | Lightweight Charts | +| IC distribution, equity curve, drawdown | Recharts / uPlot (Phase 2) | + +### 2.5. Q5 = A — Local Self-Hosted + +#### 2.5.1. Windows dev +- `terminal/` — native `npm run dev` +- `gateway/` — Python venv на Windows (pybit works on Windows) + +#### 2.5.2. qlib execution +- WSL2 + Docker Desktop +- `MODEL_CoSTEER_env_type=docker` +- Bind mount `~/.qlib` → container + +#### 2.5.3. Production path (future) +- Linux Docker Compose +- Optional cloud (Azure — RD-Agent demo already exists) + +### 2.6. Q6 = A — Tiger (Phase 4) + +- SDK: [tigerfintech/openapi-python-sdk](https://github.com/tigerfintech/openapi-python-sdk) +- MCP Server уже доступен для AI tooling +- Markets: US, HK, CN equities +- Integration via `TigerAdapter(BrokerAdapter)` + +--- + +## 3. Анализ текущего состояния (As-Is) + +### 3.1. Структура проекта + +``` +RD-Agent/ +├── rdagent/ # Core Python package (~679 files) +│ ├── app/ # CLI: fin_factor, fin_model, fin_quant, … +│ ├── components/ # CoSTEER coders, workflow +│ ├── core/ # Experiment, proposal, scenario abstractions +│ ├── log/ # Logging, trace storage, UIs +│ │ ├── server/app.py # Flask server_ui +│ │ └── ui/ # Streamlit apps +│ ├── scenarios/qlib/ # Qlib integration +│ │ └── developer/factor_runner.py +│ └── utils/env.py # Qlib Docker/Conda +├── web/ # Vue 3 + Vite (legacy → deprecated) +├── docs/ # Sphinx documentation +└── git_ignore_folder/ # Runtime: traces, static, workspace +``` + +### 3.2. UI Layer — три поверхности + +| Surface | Stack | Entry | Назначение | +|---------|-------|-------|------------| +| Vue SPA | Vue 3, Element Plus, ECharts | `rdagent server_ui` | Agent loop, upload, trace polling | +| Flask API | Flask + CORS | port 19899 | `/upload`, `/trace`, `/control` | +| Streamlit | Plotly | `rdagent ui` | qlib deep analytics, DS scenario | + +### 3.3. Flask API (existing) + +| Method | Route | Purpose | +|--------|-------|---------| +| POST | `/upload` | Start scenario subprocess | +| POST | `/trace` | Poll incremental trace messages | +| GET | `/traces` | List trace IDs | +| POST | `/control` | Stop process | +| POST | `/receive` | WebStorage log ingest | +| POST | `/user_interaction/submit` | User input to agent | + +**Limitation:** polling-only, no WebSocket, no OpenAPI spec. + +### 3.4. Qlib Integration Flow + +``` +QlibFactorRunner.develop() + → process_factor_data() # execute factor code + → deduplicate vs SOTA (IC corr) + → combined_factors_df.parquet + → QlibFBWorkspace.execute() + → Docker/conda: qrun conf_*.yaml + → read_exp_res.py → qlib_res.csv, ret.pkl + → exp.result = metrics (IC, excess return, drawdown) +``` + +**Key file:** `rdagent/scenarios/qlib/developer/factor_runner.py` + +### 3.5. Data Layer (existing) + +| Source | Mechanism | Output | +|--------|-----------|--------| +| Qlib CN | `~/.qlib/qlib_data/cn_data` | HDF5 `daily_pv.h5` | +| User upload | JSON/code via UI | `git_ignore_folder/traces/uploads/` | +| Artifacts | qlib qrun | `ret.pkl`, `qlib_res.csv`, mlruns | +| Traces | pickle | `git_ignore_folder/traces/` | + +**Gap:** нет unified REST API для market data; нет broker integration. + +### 3.6. Выявленные проблемы + +| # | Problem | Impact | PR #1 address | +|---|---------|--------|-----------------| +| 1 | Dual UI (Vue + Streamlit) | Maintenance burden | Start React parallel | +| 2 | Flask polling | Poor UX for long jobs | WebSocket in Phase 2 | +| 3 | No market data API | Can't show live charts | **Bybit via gateway** | +| 4 | No execution layer | No trading | Read-only positions Phase 1 | +| 5 | Linux-first qlib | Windows friction | WSL2 docs | +| 6 | No auth | Security risk for live trading | Phase 3 | + +### 3.7. As-Is Diagram + +```mermaid +flowchart TB + subgraph UI["UI (fragmented)"] + Vue["Vue 3 SPA"] + ST["Streamlit"] + end + + subgraph API["API"] + Flask["Flask server_ui"] + end + + subgraph Agent["RD-Agent"] + Loop["RDLoop"] + Runner["QlibFactorRunner"] + end + + subgraph Data["Data (file-based)"] + HDF5["daily_pv.h5"] + QlibData["~/.qlib/qlib_data"] + Traces["pickle traces"] + end + + Vue -->|polling| Flask + Flask --> Loop --> Runner + Runner --> QlibData + ST -.-> Traces +``` + +--- + +## 4. Целевая архитектура (To-Be) + +### 4.1. High-Level Architecture + +```mermaid +flowchart TB + subgraph Terminal["terminal/ — React 19"] + WS["Workspace Shell"] + AC["Agent Console"] + RL["Research Lab"] + EX["Execution Monitor"] + CH["Chart Panel
Lightweight Charts"] + end + + subgraph Gateway["gateway/ — FastAPI"] + REST["REST /api/v1/*"] + WSS["WebSocket /ws/*"] + end + + subgraph Core["RD-Agent Core (existing)"] + Agent["Agent Service"] + QlibRun["Qlib Runner"] + end + + subgraph Data["Data Layer"] + QlibData["qlib provider"] + BybitData["Bybit klines"] + Cache["Redis + Parquet"] + end + + subgraph Exec["Broker Adapters"] + Bybit["BybitAdapter"] + Paper["PaperSimulator"] + Tiger["TigerAdapter
Phase 4"] + end + + Terminal --> Gateway + Gateway --> Agent + Gateway --> QlibRun + Gateway --> Data + Gateway --> Exec + Bybit --> BybitAPI["Bybit REST + WS"] +``` + +### 4.2. Separation of Concerns + +| Layer | Responsibility | Changes in PR #1 | +|-------|----------------|------------------| +| **Presentation** | Terminal UI, charts, workspace | **NEW** `terminal/` | +| **Gateway** | REST/WS, routing, CORS | **NEW** `gateway/` | +| **Agent** | LLM R&D loop | No change | +| **Research** | qlib backtest, metrics | No change (Phase 2 API) | +| **Market Data** | Klines, symbols, ticker | **NEW** BybitAdapter | +| **Execution** | Orders, positions | Read-only Phase 1 | +| **Risk** | Limits, kill switch | Design only Phase 1 | + +### 4.3. Broker Adapter Pattern + +```python +# gateway/app/brokers/base.py — contract + +class BrokerAdapter(ABC): + broker_id: str + market_type: Literal["crypto", "equity", "option"] + + async def get_symbols(self) -> list[Symbol]: ... + async def get_klines(self, symbol, interval, limit) -> list[OHLCV]: ... + async def get_ticker(self, symbol) -> Ticker: ... + async def get_positions(self) -> list[Position]: ... # Phase 1 read-only + async def get_orders(self, status=None) -> list[Order]: ... # Phase 1 read-only + async def place_order(self, order: OrderRequest) -> OrderResult: ... # Phase 3 + async def cancel_order(self, order_id: str) -> bool: ... # Phase 3 +``` + +**Implementations:** + +| Adapter | Phase | Mode | +|---------|-------|------| +| `BybitAdapter` | 1 | testnet, read-only market + positions | +| `PaperAdapter` | 3 | simulated execution | +| `TigerAdapter` | 4 | equities paper → live | + +--- + +## 5. Технологический стек + +### 5.1. Frontend (`terminal/`) + +| Component | Technology | Version | Purpose | +|-----------|------------|---------|---------| +| Framework | React | 19.x | UI | +| Language | TypeScript | 5.x | Type safety | +| Build | Vite | 6.x | Dev server, HMR | +| Styling | Tailwind CSS | 4.x | Utility-first CSS | +| Components | shadcn/ui | latest | Accessible UI primitives | +| Layout | react-grid-layout | 1.x | Drag-drop workspace panels | +| Charts (market) | lightweight-charts | 5.x | OHLC, volume | +| Charts (analytics) | Recharts | 2.x | Phase 2: IC, equity | +| State | Zustand | 5.x | Client state | +| Server state | TanStack Query | 5.x | API cache, refetch | +| Routing | React Router | 7.x | SPA routes | +| HTTP | fetch / axios | — | REST client | + +### 5.2. Backend (`gateway/`) + +| Component | Technology | Purpose | +|-----------|------------|---------| +| Framework | FastAPI | 0.115+ | Async REST + WS | +| Server | Uvicorn | ASGI | +| Bybit SDK | pybit | 5.x | Official Bybit v5 | +| Validation | Pydantic | 2.x | Request/response models | +| Cache | Redis | 7.x | Klines cache (optional Phase 1) | +| Config | pydantic-settings | .env loading | + +### 5.3. Existing (unchanged in PR #1) + +| Component | Role | +|-----------|------| +| RD-Agent core | Agent orchestration | +| Qlib | Research backtest | +| Flask server_ui | Legacy, proxied in Phase 2 | +| Docker qlib image | Research execution | + +--- + +## 6. Слой данных (Data Layer) + +### 6.1. Multi-Market Data Matrix + +| Market | Source | Format | Research | Execution | Phase | +|--------|--------|--------|----------|-----------|-------| +| Crypto (Bybit) | pybit klines API | JSON → normalized OHLCV | vectorbt (Phase 2) | Bybit testnet | **1** | +| CN Equities | qlib `cn_data` | HDF5, parquet | qlib qrun | Paper only | existing | +| US Equities | — | — | qlib custom | Tiger (Phase 4) | 4 | + +### 6.2. Bybit Market Data (PR #1) + +#### 6.2.1. Endpoints used (pybit HTTP) +- `get_kline()` — historical OHLCV +- `get_tickers()` — last price, 24h change +- `get_instruments_info()` — symbol list (linear category) + +#### 6.2.2. Normalized OHLCV schema + +```typescript +interface OHLCVBar { + time: number; // Unix timestamp (seconds) + open: number; + high: number; + low: number; + close: number; + volume: number; +} +``` + +#### 6.2.3. Supported intervals (Phase 1) +`1`, `5`, `15`, `60`, `240`, `D` (Bybit interval codes) + +#### 6.2.4. Default symbols +`BTCUSDT`, `ETHUSDT`, `SOLUSDT` + +### 6.3. Caching Strategy + +| Data | TTL | Storage | Phase | +|------|-----|---------|-------| +| Klines (historical) | 60s | In-memory / Redis | 1 | +| Ticker | 5s | In-memory | 1 | +| Symbol list | 1h | In-memory | 1 | + +### 6.4. Qlib Artifacts (Phase 2) + +| File | Content | API endpoint | +|------|---------|--------------| +| `qlib_res.csv` | IC, returns, drawdown | `/research/{id}/metrics` | +| `ret.pkl` | Daily returns series | `/research/{id}/returns` | +| `combined_factors_df.parquet` | Factor values | `/research/{id}/factors` | + +--- + +## 7. Интеграция с внешними терминалами и сервисами + +### 7.1. TradingView + +| Product | License | Use in RD-Agent Terminal | +|---------|---------|--------------------------| +| **Lightweight Charts** | Apache-2.0 | **Primary** — OHLC, volume, markers | +| Charting Library | Commercial | Not planned | +| Embed Widget | Limited free | Not suitable | +| Pine Script | TV platform only | Out of scope | + +**Integration pattern:** +- Gateway serves klines JSON +- React component wraps `createChart()` from lightweight-charts +- Phase 2: overlay entry/exit markers from `ret.pkl` + +### 7.2. Bybit + +| Aspect | Detail | +|--------|--------| +| API | [Bybit V5](https://bybit-exchange.github.io/docs/v5/intro) | +| SDK | [pybit](https://github.com/bybit-exchange/pybit) | +| Auth | API Key + Secret (HMAC) | +| Testnet | `testnet=True` in pybit | +| WS | Phase 1 optional; Phase 3 for live ticker | +| Products | Linear perpetuals (USDT-M) | + +**Security:** +- Read-only API keys for Phase 1–2 +- Trade keys only Phase 3 with risk gate +- Keys in `.env`, never committed + +### 7.3. Tiger Trade (Phase 4) + +| Aspect | Detail | +|--------|--------| +| SDK | tigeropen Python | +| Markets | US, HK, CN | +| MCP | Available for AI agent integration | +| Use case | Equities execution after crypto MVP | + +### 7.4. OpenBB (Optional Phase 2+) + +| Aspect | Detail | +|--------|--------| +| ODP | Data layer abstraction | +| Workspace | Enterprise UI at pro.openbb.co | +| Integration | Custom backend with `widgets.json` | +| Use case | Macro/fundamentals widgets alongside terminal | + +--- + +## 8. GitHub-экосистема: анализ и shortlist + +### 8.1. Tier 1 — Integrate as library/module + +| Repo | Stars | Fit | Effort | License | Action | +|------|-------|-----|--------|---------|--------| +| [tradingview/lightweight-charts](https://github.com/tradingview/lightweight-charts) | 14k | 9/10 | Low | Apache-2.0 | npm install in terminal | +| [bybit-exchange/pybit](https://github.com/bybit-exchange/pybit) | 654 | 9/10 | Low | — | pip install in gateway | +| [microsoft/RD-Agent](https://github.com/microsoft/RD-Agent) | — | 10/10 | — | MIT | Base project | +| [microsoft/qlib](https://github.com/microsoft/qlib) | 40k | 10/10 | — | MIT | Research engine | + +### 8.2. Tier 2 — Architecture reference (no fork) + +| Repo | What to borrow | +|------|----------------| +| [vaughanf1/BB-Terminal](https://github.com/vaughanf1/BB-Terminal) | Command bar, amber terminal theme, workspace tabs | +| [tanishq-ctrl/market-risk-engine](https://github.com/tanishq-ctrl/market-risk-engine) | React+shadcn structure, typed API client | +| [sajalkmr/backdash](https://github.com/sajalkmr/backdash) | FastAPI+WebSocket+Celery job pattern | +| [DarkLink/QuantPits](https://github.com/DarkLink/QuantPits) | qlib rolling health dashboard patterns | +| [yuanyihan/qlib_factor_platform](https://github.com/yuanyihan/qlib_factor_platform) | IC analysis UI, akshare adapter | + +### 8.3. Tier 3 — Do not integrate + +| Repo | Reason | +|------|--------| +| Apex-Trading | Full competing platform, massive overlap | +| AgentQuant | Different agent stack (LangGraph+Gemini) | +| quantlab | Duplicates qlib workflow | + +--- + +## 9. Best Practices Matrix + +| Practice | Source | Apply when | +|----------|--------|------------| +| 3-zone UI (Console/Lab/Execution) | Apex, BB-Terminal | Phase 1 layout | +| Broker Adapter Pattern | Industry standard | Phase 1 (interface) | +| FastAPI + WebSocket | BackDash | Phase 2 agent trace | +| Testnet default | Crypto best practice | Phase 1–3 | +| Manual approval gate | Risk best practice | Phase 3 | +| Separate read/trade API keys | Security | Phase 1+ | +| OpenAPI spec | API best practice | Phase 1 gateway | +| Dark fintech theme | BB-Terminal, market-risk-engine | Phase 1 | +| `tabular-nums` for metrics | Typography best practice | Phase 1 | +| Checkpoint commits before major work | Git best practice | **Done: aa42a7b0** | + +--- + +## 10. Risk Management Layer + +### 10.1. Design Principles (Q1=C) + +Risk layer проектируется **до** первого live order. PR #1 — только design stub. + +### 10.2. Controls Matrix + +| Control | Phase | Implementation | +|---------|-------|----------------| +| Testnet default | 1 | `BYBIT_TESTNET=true` in .env | +| Read-only API keys | 1–2 | Bybit key permissions | +| Manual order approval | 3 | UI confirm dialog + server gate | +| Max notional per symbol | 3 | `RiskManager.check_order()` | +| Daily loss limit | 3 | Auto kill-switch | +| Kill switch | 3 | `POST /execution/kill-switch` | +| Audit log | 3 | SQLite/PostgreSQL | +| Separate mainnet toggle | 3 | Env + UI double confirm | + +### 10.3. PR #1 Risk Scope + +- **No order placement** +- Testnet-only configuration +- Document risk requirements in this plan +- `gateway/app/services/risk_manager.py` — stub with docstrings only + +--- + +## 11. Структура репозитория + +### 11.1. New Directories (PR #1) + +``` +RD-Agent/ +├── terminal/ # NEW — React terminal +│ ├── index.html +│ ├── package.json +│ ├── vite.config.ts +│ ├── tailwind.config.js +│ ├── tsconfig.json +│ ├── components.json # shadcn config +│ └── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── app/ +│ │ ├── router.tsx +│ │ └── providers.tsx +│ ├── pages/ +│ │ └── CommandCenter.tsx +│ ├── components/ +│ │ ├── workspace/ +│ │ │ ├── WorkspaceShell.tsx +│ │ │ ├── Panel.tsx +│ │ │ └── StatusBar.tsx +│ │ ├── charts/ +│ │ │ └── CandlestickChart.tsx +│ │ └── ui/ # shadcn components +│ ├── lib/ +│ │ ├── api.ts +│ │ ├── format.ts +│ │ └── utils.ts +│ └── stores/ +│ └── workspaceStore.ts +│ +├── gateway/ # NEW — FastAPI gateway +│ ├── pyproject.toml +│ ├── requirements.txt +│ └── app/ +│ ├── __init__.py +│ ├── main.py +│ ├── config.py +│ ├── models/ +│ │ └── market.py +│ ├── routers/ +│ │ ├── health.py +│ │ └── market.py +│ ├── brokers/ +│ │ ├── base.py +│ │ └── bybit.py +│ └── services/ +│ └── risk_manager.py # stub +│ +├── docs/terminal/ +│ └── IMPLEMENTATION_PLAN.md # THIS FILE +│ +└── docker-compose.terminal.yml # NEW +``` + +### 11.2. Unchanged (PR #1) + +``` +rdagent/ # No modifications +web/ # Legacy Vue, parallel run +``` + +--- + +## 12. API Specification + +### 12.1. PR #1 Endpoints + +#### 12.1.1. Health + +``` +GET /api/v1/health +``` + +Response: +```json +{ + "status": "ok", + "version": "0.1.0", + "brokers": ["bybit"], + "testnet": true +} +``` + +#### 12.1.2. Symbols + +``` +GET /api/v1/market/symbols?broker=bybit&category=linear +``` + +Response: +```json +{ + "broker": "bybit", + "symbols": [ + {"symbol": "BTCUSDT", "baseCoin": "BTC", "quoteCoin": "USDT", "status": "Trading"} + ] +} +``` + +#### 12.1.3. Klines + +``` +GET /api/v1/market/klines?broker=bybit&symbol=BTCUSDT&interval=60&limit=500 +``` + +Response: +```json +{ + "broker": "bybit", + "symbol": "BTCUSDT", + "interval": "60", + "bars": [ + {"time": 1716508800, "open": 64230.5, "high": 64500.0, "low": 63800.0, "close": 64350.0, "volume": 1234.56} + ] +} +``` + +#### 12.1.4. Ticker + +``` +GET /api/v1/market/ticker?broker=bybit&symbol=BTCUSDT +``` + +Response: +```json +{ + "symbol": "BTCUSDT", + "lastPrice": 64350.0, + "price24hPcnt": 2.34, + "volume24h": 123456.78, + "highPrice24h": 65000.0, + "lowPrice24h": 63000.0 +} +``` + +### 12.2. Phase 2+ Endpoints (reference) + +``` +# Agent (bridge Flask) +POST /api/v1/agent/run +GET /api/v1/agent/trace/{id} +WS /ws/agent/trace/{id} + +# Research (qlib artifacts) +GET /api/v1/research/experiments +GET /api/v1/research/{trace_id}/metrics +GET /api/v1/research/{trace_id}/returns + +# Execution (Phase 3) +GET /api/v1/execution/positions?broker=bybit +POST /api/v1/execution/orders +POST /api/v1/execution/kill-switch +WS /ws/execution/updates +``` + +--- + +## 13. Roadmap: Phase 1–4 + +### 13.1. Phase 1 — Terminal Shell + Bybit Data (PR #1, 3–4 weeks) + +| Deliverable | Status | +|-------------|--------| +| React terminal scaffold | PR #1 | +| FastAPI gateway | PR #1 | +| Bybit klines API | PR #1 | +| Lightweight Charts panel | PR #1 | +| Workspace layout | PR #1 | +| docker-compose | PR #1 | + +### 13.2. Phase 2 — Agent Console + Research Lab (4–5 weeks) + +| Deliverable | Description | +|-------------|-------------| +| Agent bridge | Proxy Flask `/upload`, `/trace` | +| WebSocket trace | Replace polling | +| qlib_reader service | Parse ret.pkl, qlib_res.csv | +| Research panels | IC, equity curve, drawdown | +| Signal overlay | Markers on Lightweight Charts | +| Vue deprecation | Redirect to terminal | + +### 13.3. Phase 3 — Full Execution + Risk (5–6 weeks) + +| Deliverable | Description | +|-------------|-------------| +| Order ticket UI | Limit/market/stop | +| RiskManager | Limits, kill switch | +| Signal export | Validated signal → order preview | +| PaperAdapter | Simulated execution | +| Live P&L stream | Bybit WS | +| Audit log | All orders tracked | + +### 13.4. Phase 4 — Multi-market Expansion + +| Deliverable | Description | +|-------------|-------------| +| TigerAdapter | US/HK/CN equities | +| OpenBB widgets | Optional macro data | +| Crypto quant R&D | RD-Agent crypto scenario or vectorbt | +| IB/Alpaca | On demand | + +--- + +## 14. PR #1 — Доскональный план реализации + +### 14.1. Общие параметры PR #1 + +| Parameter | Value | +|-----------|-------| +| Branch name | `feat/terminal-pr1-scaffold` | +| Base commit | `aa42a7b0` | +| Estimated effort | 5–7 dev-days | +| Reviewers | 1+ | +| Breaking changes | None (additive only) | + +--- + +### 14.2. Epic 1: Repository Scaffold + +#### 14.2.1. Task 1.1 — Create branch + +- [ ] **1.1.1** Checkout from `aa42a7b0` + ```bash + git checkout -b feat/terminal-pr1-scaffold aa42a7b0 + ``` +- [ ] **1.1.2** Verify clean working tree + +#### 14.2.2. Task 1.2 — Directory structure + +- [ ] **1.2.1** Create `terminal/` root +- [ ] **1.2.2** Create `gateway/` root +- [ ] **1.2.3** Create `docs/terminal/` (this file) +- [ ] **1.2.4** Add entries to root `.gitignore`: + - `terminal/node_modules/` + - `terminal/dist/` + - `gateway/.venv/` + - `gateway/__pycache__/` + +**Acceptance:** Directories exist, gitignore updated. + +--- + +### 14.3. Epic 2: Gateway (FastAPI + Bybit) + +#### 14.3.1. Task 2.1 — Python project setup + +- [ ] **2.1.1** Create `gateway/pyproject.toml`: + - name: `rdagent-gateway` + - python: `>=3.10` + - dependencies: `fastapi`, `uvicorn[standard]`, `pybit`, `pydantic-settings`, `httpx` +- [ ] **2.1.2** Create `gateway/requirements.txt` (pinned versions) +- [ ] **2.1.3** Create `gateway/app/__init__.py` + +#### 14.3.2. Task 2.2 — Configuration + +- [ ] **2.2.1** Create `gateway/app/config.py`: + ```python + class Settings(BaseSettings): + gateway_host: str = "0.0.0.0" + gateway_port: int = 6900 + cors_origins: list[str] = ["http://localhost:5173"] + bybit_testnet: bool = True + bybit_api_key: str = "" + bybit_api_secret: str = "" + ``` +- [ ] **2.2.2** Add to `.env.example`: + ```env + # Terminal Gateway (Bybit) + GATEWAY_PORT=6900 + BYBIT_TESTNET=true + BYBIT_API_KEY= + BYBIT_API_SECRET= + ``` +- [ ] **2.2.3** Document: empty keys OK for public market data + +#### 14.3.3. Task 2.3 — Broker Adapter interface + +- [ ] **2.3.1** Create `gateway/app/brokers/base.py`: + - `Symbol`, `OHLCVBar`, `Ticker` Pydantic models + - `BrokerAdapter` ABC with methods from §4.3 +- [ ] **2.3.2** Create `BrokerRegistry` dict for adapter lookup + +#### 14.3.4. Task 2.4 — BybitAdapter implementation + +- [ ] **2.4.1** Create `gateway/app/brokers/bybit.py` +- [ ] **2.4.2** Implement `get_symbols()`: + - pybit `HTTP.get_instruments_info(category="linear")` + - Filter `status == "Trading"` + - Map to `Symbol` model +- [ ] **2.4.3** Implement `get_klines()`: + - pybit `HTTP.get_kline(category="linear", symbol, interval, limit)` + - Map to `OHLCVBar[]`, sort by time ascending + - Handle pagination if limit > 1000 +- [ ] **2.4.4** Implement `get_ticker()`: + - pybit `HTTP.get_tickers(category="linear", symbol)` +- [ ] **2.4.5** Error handling: + - Bybit API errors → HTTP 502 with message + - Invalid symbol → HTTP 404 + - Rate limit → HTTP 429 +- [ ] **2.4.6** Unit tests with mocked pybit responses + +#### 14.3.5. Task 2.5 — API Routers + +- [ ] **2.5.1** Create `gateway/app/routers/health.py` +- [ ] **2.5.2** Create `gateway/app/routers/market.py`: + - `GET /symbols` + - `GET /klines` + - `GET /ticker` +- [ ] **2.5.3** Create `gateway/app/main.py`: + - FastAPI app factory + - CORS middleware + - Include routers under `/api/v1` + - OpenAPI at `/docs` + +#### 14.3.6. Task 2.6 — Risk manager stub + +- [ ] **2.6.1** Create `gateway/app/services/risk_manager.py` +- [ ] **2.6.2** Docstring-only class with planned methods +- [ ] **2.6.3** No enforcement in PR #1 + +#### 14.3.7. Task 2.7 — Gateway CLI entry + +- [ ] **2.7.1** Add run script or document: + ```bash + cd gateway && uvicorn app.main:app --host 0.0.0.0 --port 6900 --reload + ``` + +**Acceptance Epic 2:** +- `GET /api/v1/health` returns 200 +- `GET /api/v1/market/klines?symbol=BTCUSDT&interval=60&limit=100` returns valid OHLCV +- OpenAPI docs accessible at `http://localhost:6900/docs` + +--- + +### 14.4. Epic 3: Terminal (React + Charts) + +#### 14.4.1. Task 3.1 — Vite project scaffold + +- [ ] **3.1.1** Initialize with `npm create vite@latest terminal -- --template react-ts` +- [ ] **3.1.2** Install dependencies: + ```bash + npm install react-router-dom @tanstack/react-query zustand + npm install lightweight-charts react-grid-layout + npm install -D tailwindcss @tailwindcss/vite + ``` +- [ ] **3.1.3** Initialize shadcn/ui: + ```bash + npx shadcn@latest init + npx shadcn@latest add button select badge separator + ``` + +#### 14.4.2. Task 3.2 — Theme and layout + +- [ ] **3.2.1** Configure Tailwind dark theme: + - Background: `#0a0e17` + - Accent: amber `#f59e0b`, green `#22c55e` + - Font: system + monospace for numbers +- [ ] **3.2.2** Create `src/app/providers.tsx`: + - QueryClientProvider + - Theme provider (dark default) +- [ ] **3.2.3** Create `src/app/router.tsx`: + - `/` → CommandCenter + - `/chart` → optional full-screen chart + +#### 14.4.3. Task 3.3 — API client + +- [ ] **3.3.1** Create `src/lib/api.ts`: + ```typescript + const BASE = import.meta.env.VITE_GATEWAY_URL ?? "http://localhost:6900"; + export async function fetchKlines(symbol, interval, limit): Promise + export async function fetchTicker(symbol): Promise + export async function fetchSymbols(): Promise + ``` +- [ ] **3.3.2** Create `src/lib/types.ts` — mirror gateway Pydantic models +- [ ] **3.3.3** Create TanStack Query hooks: + - `useKlines(symbol, interval)` + - `useTicker(symbol)` + - `useSymbols()` + +#### 14.4.4. Task 3.4 — Workspace shell + +- [ ] **3.4.1** Create `src/stores/workspaceStore.ts`: + - `layout: Layout[]` + - `activeSymbol: string` + - `activeInterval: string` + - `saveLayout()`, `loadLayout()` from localStorage +- [ ] **3.4.2** Create `src/components/workspace/WorkspaceShell.tsx`: + - react-grid-layout with default panels: + - `chart` (8 cols × 12 rows) + - `ticker-info` (4 cols × 4 rows) + - `agent-placeholder` (4 cols × 8 rows) + - `execution-placeholder` (4 cols × 12 rows) +- [ ] **3.4.3** Create `src/components/workspace/StatusBar.tsx`: + - Gateway connection status + - Bybit testnet indicator + - Active symbol + last price +- [ ] **3.4.4** Create `src/components/workspace/Panel.tsx`: + - Header with title + collapse + - Children slot + +#### 14.4.5. Task 3.5 — Candlestick chart component + +- [ ] **3.5.1** Create `src/components/charts/CandlestickChart.tsx`: + - `useRef` for container div + - `createChart()` on mount + - `addSeries(CandlestickSeries)` + `addSeries(HistogramSeries)` for volume + - `setData()` when klines change + - `resize()` on container resize (ResizeObserver) + - `remove()` on unmount +- [ ] **3.5.2** Chart options: + - Dark theme matching terminal + - Time scale: visible, right offset + - Crosshair enabled +- [ ] **3.5.3** Loading state: skeleton while fetching +- [ ] **3.5.4** Error state: retry button + +#### 14.4.6. Task 3.6 — CommandCenter page + +- [ ] **3.6.1** Create `src/pages/CommandCenter.tsx`: + - Top bar: "RD-Agent Terminal" + symbol selector + interval selector + - WorkspaceShell with panels + - Chart panel → CandlestickChart + - Ticker panel → 24h stats from useTicker + - Agent placeholder → "Agent Console — Phase 2" + - Execution placeholder → "Execution Monitor — Phase 3" +- [ ] **3.6.2** Symbol selector: dropdown from useSymbols, default BTCUSDT +- [ ] **3.6.3** Interval selector: 1m, 5m, 15m, 1h, 4h, 1D + +#### 14.4.7. Task 3.7 — Vite proxy config + +- [ ] **3.7.1** Configure `vite.config.ts`: + ```typescript + server: { + proxy: { + '/api': 'http://localhost:6900' + } + } + ``` +- [ ] **3.7.2** Env file `terminal/.env.development`: + ``` + VITE_GATEWAY_URL=http://localhost:6900 + ``` + +**Acceptance Epic 3:** +- `npm run dev` starts on :5173 +- Chart renders BTCUSDT 1h candles +- Symbol/interval change triggers refetch + chart update +- Layout panels draggable and resizable +- Dark theme applied consistently + +--- + +### 14.5. Epic 4: DevOps & Documentation + +#### 14.5.1. Task 4.1 — Docker Compose + +- [ ] **4.1.1** Create `docker-compose.terminal.yml`: + ```yaml + services: + gateway: + build: ./gateway + ports: ["6900:6900"] + env_file: .env + redis: + image: redis:7-alpine + ports: ["6379:6379"] + profiles: ["cache"] + ``` +- [ ] **4.1.2** Create `gateway/Dockerfile`: + ```dockerfile + FROM python:3.11-slim + WORKDIR /app + COPY requirements.txt . + RUN pip install --no-cache-dir -r requirements.txt + COPY app/ app/ + CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "6900"] + ``` + +#### 14.5.2. Task 4.2 — Development guide + +- [ ] **4.2.1** Create `docs/terminal/DEVELOPMENT.md`: + - Prerequisites (Node 20+, Python 3.10+, optional Docker) + - Gateway setup + - Terminal setup + - Bybit testnet API key creation guide + - Windows + WSL2 notes for qlib (reference only) + - Troubleshooting (CORS, API errors) + +#### 14.5.3. Task 4.3 — Root README update + +- [ ] **4.3.1** Add section "RD-Agent Terminal" with link to docs +- [ ] **4.3.2** Quick start commands + +#### 14.5.4. Task 4.4 — CI consideration (optional PR #1) + +- [ ] **4.4.1** Note: add GitHub Actions for gateway lint/test in Phase 2 +- [ ] **4.4.2** PR #1: manual test checklist sufficient + +**Acceptance Epic 4:** +- `docker compose -f docker-compose.terminal.yml up gateway` works +- DEVELOPMENT.md enables new developer setup in <30 min + +--- + +### 14.6. Epic 5: Testing & QA + +#### 14.6.1. Task 5.1 — Gateway tests + +- [ ] **5.1.1** Create `gateway/tests/test_market.py` +- [ ] **5.1.2** Test health endpoint +- [ ] **5.1.3** Test klines response schema (mocked Bybit) +- [ ] **5.1.4** Test error handling (invalid symbol) + +#### 14.6.2. Task 5.2 — Manual QA checklist + +- [ ] **5.2.1** Gateway starts without Bybit keys (public data) +- [ ] **5.2.2** Gateway starts with Bybit testnet keys +- [ ] **5.2.3** Terminal loads chart for BTCUSDT +- [ ] **5.2.4** Switch symbol to ETHUSDT — chart updates +- [ ] **5.2.5** Switch interval 1h → 4h — chart updates +- [ ] **5.2.6** Resize panel — chart resizes +- [ ] **5.2.7** Drag panel — layout persists after refresh +- [ ] **5.2.8** Gateway down — terminal shows error state +- [ ] **5.2.9** Existing `rdagent server_ui` still works (no regression) + +#### 14.6.3. Task 5.3 — Performance baseline + +- [ ] **5.3.1** Klines 500 bars load < 2s +- [ ] **5.3.2** Chart render < 100ms after data received +- [ ] **5.3.3** Memory stable on symbol switch (no chart leak) + +**Acceptance Epic 5:** +- All manual QA items pass +- Gateway unit tests pass + +--- + +### 14.7. Epic 6: PR Submission + +#### 14.7.1. Task 6.1 — Code review prep + +- [ ] **6.1.1** Self-review diff +- [ ] **6.1.2** No secrets in committed files +- [ ] **6.1.3** No modifications to `rdagent/` core (except .env.example) +- [ ] **6.1.4** No modifications to `web/` Vue app + +#### 14.7.2. Task 6.2 — PR description + +- [ ] **6.2.1** Title: `feat(terminal): PR #1 scaffold — React terminal + FastAPI gateway + Bybit klines` +- [ ] **6.2.2** Body sections: + - Summary (3 bullets) + - Architecture diagram (link to this doc) + - Test plan (QA checklist) + - Screenshots (chart panel) + - Breaking changes: None + - Follow-up: Phase 2 agent bridge + +#### 14.7.3. Task 6.3 — Commit strategy + +- [ ] **6.3.1** Commit 1: `feat(gateway): FastAPI scaffold with BybitAdapter` +- [ ] **6.3.2** Commit 2: `feat(terminal): React workspace with Lightweight Charts` +- [ ] **6.3.3** Commit 3: `docs(terminal): implementation plan and dev guide` +- [ ] **6.3.4** Commit 4: `chore: docker-compose and env example` + +--- + +### 14.8. PR #1 File Manifest + +| File | Action | Epic | +|------|--------|------| +| `terminal/package.json` | CREATE | 3 | +| `terminal/vite.config.ts` | CREATE | 3 | +| `terminal/tsconfig.json` | CREATE | 3 | +| `terminal/tailwind.config.js` | CREATE | 3 | +| `terminal/index.html` | CREATE | 3 | +| `terminal/src/main.tsx` | CREATE | 3 | +| `terminal/src/App.tsx` | CREATE | 3 | +| `terminal/src/app/router.tsx` | CREATE | 3 | +| `terminal/src/app/providers.tsx` | CREATE | 3 | +| `terminal/src/pages/CommandCenter.tsx` | CREATE | 3 | +| `terminal/src/components/workspace/WorkspaceShell.tsx` | CREATE | 3 | +| `terminal/src/components/workspace/Panel.tsx` | CREATE | 3 | +| `terminal/src/components/workspace/StatusBar.tsx` | CREATE | 3 | +| `terminal/src/components/charts/CandlestickChart.tsx` | CREATE | 3 | +| `terminal/src/lib/api.ts` | CREATE | 3 | +| `terminal/src/lib/types.ts` | CREATE | 3 | +| `terminal/src/stores/workspaceStore.ts` | CREATE | 3 | +| `gateway/pyproject.toml` | CREATE | 2 | +| `gateway/requirements.txt` | CREATE | 2 | +| `gateway/Dockerfile` | CREATE | 4 | +| `gateway/app/main.py` | CREATE | 2 | +| `gateway/app/config.py` | CREATE | 2 | +| `gateway/app/brokers/base.py` | CREATE | 2 | +| `gateway/app/brokers/bybit.py` | CREATE | 2 | +| `gateway/app/routers/health.py` | CREATE | 2 | +| `gateway/app/routers/market.py` | CREATE | 2 | +| `gateway/app/services/risk_manager.py` | CREATE | 2 | +| `gateway/tests/test_market.py` | CREATE | 5 | +| `docker-compose.terminal.yml` | CREATE | 4 | +| `docs/terminal/IMPLEMENTATION_PLAN.md` | CREATE | 4 | +| `docs/terminal/DEVELOPMENT.md` | CREATE | 4 | +| `.env.example` | MODIFY | 2 | +| `.gitignore` | MODIFY | 1 | +| `README.md` | MODIFY | 4 | + +**Total new files:** ~35 +**Modified existing:** 3 +**Untouched:** `rdagent/`, `web/` + +--- + +### 14.9. PR #1 Timeline + +| Day | Tasks | Epic | +|-----|-------|------| +| D1 | Branch, directory scaffold, gateway config + base adapter | 1, 2.1–2.3 | +| D2 | BybitAdapter full implementation + routers | 2.4–2.7 | +| D3 | Gateway tests, Vite scaffold, theme | 2, 3.1–3.2 | +| D4 | API client, workspace shell, chart component | 3.3–3.5 | +| D5 | CommandCenter page, proxy, integration testing | 3.6–3.7, 5 | +| D6 | Docker compose, DEVELOPMENT.md, README | 4 | +| D7 | QA checklist, PR prep, screenshots | 5, 6 | + +--- + +### 14.10. PR #1 Dependencies + +| Dependency | Version | Purpose | +|------------|---------|---------| +| Node.js | 20+ | Terminal build | +| Python | 3.10+ | Gateway | +| pybit | 5.11+ | Bybit API | +| fastapi | 0.115+ | Gateway framework | +| lightweight-charts | 5.1+ | Chart rendering | +| react-grid-layout | 1.4+ | Workspace panels | + +**External services:** +- Bybit testnet API (public klines work without keys) +- Optional: Bybit testnet account for positions (Phase 1 read-only extension) + +--- + +### 14.11. PR #1 Out of Scope + +Explicitly **NOT** in PR #1: + +- Agent Console migration from Vue +- Flask proxy / agent bridge +- WebSocket implementation +- Order placement +- qlib metrics API +- Tiger adapter +- Authentication +- Redis caching (optional, not required) +- Production deployment hardening +- Changes to `rdagent/` core +- Changes to `web/` Vue app +- Removal of Streamlit + +--- + +## 15. Local Deployment (Windows + WSL2) + +### 15.1. Prerequisites + +| Tool | Version | Purpose | +|------|---------|---------| +| Node.js | 20+ | Terminal dev | +| Python | 3.10+ | Gateway | +| Git | 2.x | Version control | +| Docker Desktop | latest | Optional gateway container | +| WSL2 | Ubuntu 22.04 | qlib backtests (not PR #1) | + +### 15.2. Quick Start (PR #1) + +```powershell +# Terminal 1 — Gateway +cd gateway +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 6900 --reload + +# Terminal 2 — Frontend +cd terminal +npm install +npm run dev +# Open http://localhost:5173 +``` + +### 15.3. Environment Variables + +```env +# .env (copy from .env.example) +GATEWAY_PORT=6900 +BYBIT_TESTNET=true +BYBIT_API_KEY=your_testnet_key +BYBIT_API_SECRET=your_testnet_secret +``` + +### 15.4. WSL2 for Qlib (Phase 2+, reference) + +```bash +# In WSL2 Ubuntu +docker build -t local_qlib:latest -f rdagent/scenarios/qlib/docker/Dockerfile . +# Set in .env: MODEL_CoSTEER_env_type=docker +``` + +### 15.5. Port Map + +| Service | Port | PR | +|---------|------|-----| +| Terminal (Vite) | 5173 | 1 | +| Gateway (FastAPI) | 6900 | 1 | +| Flask server_ui (legacy) | 19899 | existing | +| Redis (optional) | 6379 | 2 | + +--- + +## 16. Критерии приёмки и Definition of Done + +### 16.1. PR #1 Definition of Done + +- [ ] All Epic 1–6 tasks completed +- [ ] Manual QA checklist (§14.6.2) — all pass +- [ ] Gateway unit tests pass +- [ ] No secrets committed +- [ ] Documentation complete (this file + DEVELOPMENT.md) +- [ ] Existing RD-Agent functionality unaffected +- [ ] PR description with screenshots +- [ ] Code review approved + +### 16.2. Functional Acceptance Criteria + +| # | Criterion | Verification | +|---|-----------|--------------| +| AC-1 | Gateway health endpoint returns 200 | curl /api/v1/health | +| AC-2 | Klines endpoint returns ≥100 bars for BTCUSDT | curl /api/v1/market/klines | +| AC-3 | Terminal renders candlestick chart | Visual | +| AC-4 | Symbol change updates chart | Manual test | +| AC-5 | Interval change updates chart | Manual test | +| AC-6 | Workspace panels draggable/resizable | Manual test | +| AC-7 | Layout persists in localStorage | Refresh browser | +| AC-8 | Error state when gateway down | Stop gateway, check UI | +| AC-9 | Dark theme consistent | Visual review | +| AC-10 | OpenAPI docs at /docs | Browser | + +### 16.3. Non-Functional Criteria + +| # | Criterion | Target | +|---|-----------|--------| +| NF-1 | Klines API latency | < 2s for 500 bars | +| NF-2 | Chart render time | < 100ms | +| NF-3 | No memory leak on symbol switch | DevTools heap stable | +| NF-4 | Gateway startup time | < 5s | + +--- + +## 17. Rollback и управление версиями + +### 17.1. Checkpoint Commit + +``` +Commit: aa42a7b0 +Message: checkpoint: save return point before terminal PR #1 +Date: 2026-05-23 +``` + +### 17.2. Rollback Procedure + +```bash +# Full rollback to pre-terminal state +git checkout aa42a7b0 + +# Or revert PR #1 merge commit (after merge) +git revert -m 1 + +# Or reset branch (destructive, local only) +git reset --hard aa42a7b0 +``` + +### 17.3. Branch Strategy + +``` +main + └── feat/terminal-pr1-scaffold ← PR #1 + └── (future) feat/terminal-pr2-agent-bridge +``` + +### 17.4. Coexistence Rules + +| Component | During PR #1 | After Phase 2 | +|-----------|--------------|---------------| +| `web/` (Vue) | Active, unchanged | Deprecated | +| `terminal/` | New, primary dev | Primary UI | +| Flask server_ui | Active | Proxied via gateway | +| Streamlit | Active | Debug only | + +--- + +## 18. Открытые вопросы и решения по умолчанию + +| # | Question | Default | Decide by | +|---|----------|---------|-----------| +| 1 | Crypto backtest engine | vectorbt | Phase 2 | +| 2 | Experiment DB | SQLite → PostgreSQL | Phase 2 | +| 3 | Auth model | None (local) → API key | Phase 3 | +| 4 | Bybit product focus | Linear USDT perps | Phase 1 ✓ | +| 5 | Redis in Phase 1 | Optional, skip | Phase 2 | +| 6 | WebSocket ticker | REST polling 5s | Phase 1 ✓ | +| 7 | RD-Agent crypto scenario | Phase 4 | Later | +| 8 | CI for terminal/gateway | Manual QA only | Phase 2 | + +--- + +## 19. Приложения + +### 19.1. Appendix A — Terminal UI Wireframe + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ RD-Agent Terminal [BTCUSDT ▼] [1h ▼] Last: 64,350 +2.34% │ +├──────────┬──────────────────────────────────────────────┬─────────────┤ +│ │ │ 24h Stats │ +│ NAV │ LIGHTWEIGHT CHARTS │ High: 65000 │ +│ │ (Candlestick + Volume) │ Low: 63000 │ +│ ◉ Market │ │ Vol: 123K │ +│ ○ Agent │ ├─────────────┤ +│ ○ Research│ │ Agent │ +│ ○ Execute│ │ Console │ +│ ○ Risk │ │ Phase 2 │ +│ │ ├─────────────┤ +│ │ │ Execution │ +│ │ │ Monitor │ +│ │ │ Phase 3 │ +├──────────┴──────────────────────────────────────────────┴─────────────┤ +│ Gateway: ● connected │ Bybit: testnet │ qlib: WSL2 docker │ v0.1.0 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 19.2. Appendix B — Bybit Interval Mapping + +| UI Label | Bybit interval param | +|----------|---------------------| +| 1m | `1` | +| 5m | `5` | +| 15m | `15` | +| 1h | `60` | +| 4h | `240` | +| 1D | `D` | + +### 19.3. Appendix C — Related Documentation + +| Document | Path | +|----------|------| +| This plan | `docs/terminal/IMPLEMENTATION_PLAN.md` | +| Dev guide | `docs/terminal/DEVELOPMENT.md` (PR #1) | +| Existing UI docs | `docs/ui.rst` | +| Qlib quant agent | `docs/scens/quant_agent_fin.rst` | +| Env template | `.env.example` | + +### 19.4. Appendix D — Glossary + +| Term | Definition | +|------|------------| +| Agent Console | UI for RD-Agent LLM loop | +| Broker Adapter | Pluggable interface for market/execution providers | +| Gateway | FastAPI service between terminal and backends | +| Research Lab | qlib metrics and factor analysis UI | +| SOTA | State-of-the-art experiment in RD-Agent loop | +| Trace | Serialized log of one agent run | +| Workspace | Drag-drop panel layout in terminal | + +--- + +*Document maintained as part of RD-Agent Terminal project. Update version on each phase transition.* From 3a64cddc2a51e68ed7ba28362c1d5730f3fa3ce1 Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:39:09 +0300 Subject: [PATCH 04/16] chore(terminal): scaffold directory structure for PR #1 Co-authored-by: Cursor --- .gitignore | 7 +++++++ gateway/app/brokers/.gitkeep | 0 gateway/app/models/.gitkeep | 0 gateway/app/routers/.gitkeep | 0 gateway/app/services/.gitkeep | 0 gateway/tests/.gitkeep | 0 terminal/src/app/.gitkeep | 0 terminal/src/components/charts/.gitkeep | 0 terminal/src/components/ui/.gitkeep | 0 terminal/src/components/workspace/.gitkeep | 0 terminal/src/lib/.gitkeep | 0 terminal/src/pages/.gitkeep | 0 terminal/src/stores/.gitkeep | 0 13 files changed, 7 insertions(+) create mode 100644 gateway/app/brokers/.gitkeep create mode 100644 gateway/app/models/.gitkeep create mode 100644 gateway/app/routers/.gitkeep create mode 100644 gateway/app/services/.gitkeep create mode 100644 gateway/tests/.gitkeep create mode 100644 terminal/src/app/.gitkeep create mode 100644 terminal/src/components/charts/.gitkeep create mode 100644 terminal/src/components/ui/.gitkeep create mode 100644 terminal/src/components/workspace/.gitkeep create mode 100644 terminal/src/lib/.gitkeep create mode 100644 terminal/src/pages/.gitkeep create mode 100644 terminal/src/stores/.gitkeep diff --git a/.gitignore b/.gitignore index 6ff575f76..ed7c0ab4d 100644 --- a/.gitignore +++ b/.gitignore @@ -188,3 +188,10 @@ AGENTS.md !rdagent/**/AGENTS.md scripts/ + +# RD-Agent Terminal (PR #1) +terminal/node_modules/ +terminal/dist/ +gateway/.venv/ +gateway/**/__pycache__/ +!terminal/src/lib/ diff --git a/gateway/app/brokers/.gitkeep b/gateway/app/brokers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/gateway/app/models/.gitkeep b/gateway/app/models/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/gateway/app/routers/.gitkeep b/gateway/app/routers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/gateway/app/services/.gitkeep b/gateway/app/services/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/gateway/tests/.gitkeep b/gateway/tests/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/terminal/src/app/.gitkeep b/terminal/src/app/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/terminal/src/components/charts/.gitkeep b/terminal/src/components/charts/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/terminal/src/components/ui/.gitkeep b/terminal/src/components/ui/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/terminal/src/components/workspace/.gitkeep b/terminal/src/components/workspace/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/terminal/src/lib/.gitkeep b/terminal/src/lib/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/terminal/src/pages/.gitkeep b/terminal/src/pages/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/terminal/src/stores/.gitkeep b/terminal/src/stores/.gitkeep new file mode 100644 index 000000000..e69de29bb From 46cd44620284610d7b03fdd0b82e0c41b08a9259 Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:40:04 +0300 Subject: [PATCH 05/16] feat(gateway): config, pydantic models, and broker adapter interface Co-authored-by: Cursor --- .env.example | 10 +++++++ gateway/app/__init__.py | 1 + gateway/app/brokers/.gitkeep | 0 gateway/app/brokers/base.py | 42 ++++++++++++++++++++++++++ gateway/app/config.py | 21 +++++++++++++ gateway/app/main.py | 30 +++++++++++++++++++ gateway/app/models/.gitkeep | 0 gateway/app/models/market.py | 45 ++++++++++++++++++++++++++++ gateway/app/services/.gitkeep | 0 gateway/app/services/risk_manager.py | 26 ++++++++++++++++ gateway/pyproject.toml | 26 ++++++++++++++++ gateway/requirements.txt | 7 +++++ 12 files changed, 208 insertions(+) create mode 100644 gateway/app/__init__.py delete mode 100644 gateway/app/brokers/.gitkeep create mode 100644 gateway/app/brokers/base.py create mode 100644 gateway/app/config.py create mode 100644 gateway/app/main.py delete mode 100644 gateway/app/models/.gitkeep create mode 100644 gateway/app/models/market.py delete mode 100644 gateway/app/services/.gitkeep create mode 100644 gateway/app/services/risk_manager.py create mode 100644 gateway/pyproject.toml create mode 100644 gateway/requirements.txt diff --git a/.env.example b/.env.example index 752dc3694..51d612fef 100644 --- a/.env.example +++ b/.env.example @@ -71,4 +71,14 @@ EMBEDDING_MODEL="litellm_proxy/BAAI/bge-large-en-v1.5" # QLIB_DOCKER_local_qlib_repo_path=C:\Users\QWERTY\Desktop\QLIB\qlib # # Qlib data cache is still shared via ~/.qlib -> /root/.qlib in the container (download once on the host). +# ========================================== + +# ========================================== +# RD-Agent Terminal Gateway (Bybit market data) +# ========================================== +# GATEWAY_PORT=6900 +# BYBIT_TESTNET=true +# BYBIT_API_KEY= +# BYBIT_API_SECRET= +# Empty keys are OK for public klines/tickers on testnet. # ========================================== \ No newline at end of file diff --git a/gateway/app/__init__.py b/gateway/app/__init__.py new file mode 100644 index 000000000..076b5be8c --- /dev/null +++ b/gateway/app/__init__.py @@ -0,0 +1 @@ +"""RD-Agent Terminal API gateway.""" diff --git a/gateway/app/brokers/.gitkeep b/gateway/app/brokers/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/gateway/app/brokers/base.py b/gateway/app/brokers/base.py new file mode 100644 index 000000000..b44fe95c3 --- /dev/null +++ b/gateway/app/brokers/base.py @@ -0,0 +1,42 @@ +from abc import ABC, abstractmethod + +from app.models.market import OHLCVBar, Symbol, Ticker + +BROKER_REGISTRY: dict[str, type["BrokerAdapter"]] = {} + + +def register_broker(broker_id: str): + def decorator(cls: type["BrokerAdapter"]) -> type["BrokerAdapter"]: + BROKER_REGISTRY[broker_id] = cls + return cls + + return decorator + + +def get_broker(broker_id: str, **kwargs) -> "BrokerAdapter": + if broker_id not in BROKER_REGISTRY: + raise KeyError(f"Unknown broker: {broker_id}") + return BROKER_REGISTRY[broker_id](**kwargs) + + +class BrokerAdapter(ABC): + broker_id: str + market_type: str = "crypto" + + @abstractmethod + async def get_symbols(self, category: str = "linear") -> list[Symbol]: + raise NotImplementedError + + @abstractmethod + async def get_klines( + self, + symbol: str, + interval: str, + limit: int = 500, + category: str = "linear", + ) -> list[OHLCVBar]: + raise NotImplementedError + + @abstractmethod + async def get_ticker(self, symbol: str, category: str = "linear") -> Ticker: + raise NotImplementedError diff --git a/gateway/app/config.py b/gateway/app/config.py new file mode 100644 index 000000000..aaa6fdd50 --- /dev/null +++ b/gateway/app/config.py @@ -0,0 +1,21 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + gateway_host: str = "0.0.0.0" + gateway_port: int = 6900 + cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"] + app_version: str = "0.1.0" + + bybit_testnet: bool = True + bybit_api_key: str = "" + bybit_api_secret: str = "" + + +settings = Settings() diff --git a/gateway/app/main.py b/gateway/app/main.py new file mode 100644 index 000000000..a36a16c5e --- /dev/null +++ b/gateway/app/main.py @@ -0,0 +1,30 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings + + +def create_app() -> FastAPI: + app = FastAPI( + title="RD-Agent Terminal Gateway", + version=settings.app_version, + docs_url="/docs", + redoc_url="/redoc", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/") + async def root() -> dict[str, str]: + return {"service": "rdagent-gateway", "docs": "/docs"} + + return app + + +app = create_app() diff --git a/gateway/app/models/.gitkeep b/gateway/app/models/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/gateway/app/models/market.py b/gateway/app/models/market.py new file mode 100644 index 000000000..c7a2d14fb --- /dev/null +++ b/gateway/app/models/market.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel, Field + + +class Symbol(BaseModel): + symbol: str + baseCoin: str + quoteCoin: str + status: str + + +class OHLCVBar(BaseModel): + time: int = Field(..., description="Unix timestamp in seconds") + open: float + high: float + low: float + close: float + volume: float + + +class Ticker(BaseModel): + symbol: str + lastPrice: float + price24hPcnt: float + volume24h: float + highPrice24h: float + lowPrice24h: float + + +class HealthResponse(BaseModel): + status: str + version: str + brokers: list[str] + testnet: bool + + +class SymbolsResponse(BaseModel): + broker: str + symbols: list[Symbol] + + +class KlinesResponse(BaseModel): + broker: str + symbol: str + interval: str + bars: list[OHLCVBar] diff --git a/gateway/app/services/.gitkeep b/gateway/app/services/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/gateway/app/services/risk_manager.py b/gateway/app/services/risk_manager.py new file mode 100644 index 000000000..3b7540e6a --- /dev/null +++ b/gateway/app/services/risk_manager.py @@ -0,0 +1,26 @@ +"""Risk management service (Phase 3). + +Planned controls: +- kill switch: halt all new orders immediately +- max notional per symbol +- daily loss limit with auto kill-switch +- manual approval gate before live order execution +""" + + +class RiskManager: + """Stub for Phase 3 execution risk controls.""" + + def check_order(self, *args, **kwargs) -> bool: + """Validate order against risk limits. Not enforced in PR #1.""" + raise NotImplementedError("RiskManager is a Phase 3 stub") + + def is_kill_switch_active(self) -> bool: + """Return True if kill switch is engaged.""" + return False + + def activate_kill_switch(self, reason: str) -> None: + """Engage kill switch and block new orders.""" + + def deactivate_kill_switch(self) -> None: + """Disengage kill switch after manual review.""" diff --git a/gateway/pyproject.toml b/gateway/pyproject.toml new file mode 100644 index 000000000..711da8f35 --- /dev/null +++ b/gateway/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "rdagent-gateway" +version = "0.1.0" +description = "FastAPI gateway for RD-Agent Terminal" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pybit>=5.11.0", + "pydantic-settings>=2.6.0", + "httpx>=0.28.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/gateway/requirements.txt b/gateway/requirements.txt new file mode 100644 index 000000000..361a7b19a --- /dev/null +++ b/gateway/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +pybit>=5.11.0 +pydantic-settings>=2.6.0 +httpx>=0.28.0 +pytest>=8.3.0 +pytest-asyncio>=0.24.0 From 88f73cf9fc5489868ed0c7b6b540d0156416f68b Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:40:20 +0300 Subject: [PATCH 06/16] feat(gateway): implement BybitAdapter for read-only market data Co-authored-by: Cursor --- gateway/app/brokers/bybit.py | 111 ++++++++++++++++++++++++++++++++++ gateway/app/brokers/errors.py | 17 ++++++ gateway/tests/test_bybit.py | 102 +++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 gateway/app/brokers/bybit.py create mode 100644 gateway/app/brokers/errors.py create mode 100644 gateway/tests/test_bybit.py diff --git a/gateway/app/brokers/bybit.py b/gateway/app/brokers/bybit.py new file mode 100644 index 000000000..596cd141b --- /dev/null +++ b/gateway/app/brokers/bybit.py @@ -0,0 +1,111 @@ +import asyncio +from typing import Any + +from pybit.unified_trading import HTTP + +from app.brokers.base import register_broker +from app.brokers.errors import BrokerNotFoundError, BrokerUpstreamError +from app.config import settings +from app.models.market import OHLCVBar, Symbol, Ticker + + +@register_broker("bybit") +class BybitAdapter: + broker_id = "bybit" + market_type = "crypto" + + def __init__( + self, + testnet: bool | None = None, + api_key: str | None = None, + api_secret: str | None = None, + ) -> None: + self._testnet = settings.bybit_testnet if testnet is None else testnet + self._client = HTTP( + testnet=self._testnet, + api_key=api_key if api_key is not None else settings.bybit_api_key or None, + api_secret=api_secret if api_secret is not None else settings.bybit_api_secret or None, + ) + + async def get_symbols(self, category: str = "linear") -> list[Symbol]: + response = await asyncio.to_thread( + self._client.get_instruments_info, + category=category, + ) + self._ensure_success(response) + rows = response.get("result", {}).get("list", []) + symbols: list[Symbol] = [] + for row in rows: + if row.get("status") != "Trading": + continue + symbols.append( + Symbol( + symbol=row["symbol"], + baseCoin=row.get("baseCoin", ""), + quoteCoin=row.get("quoteCoin", ""), + status=row.get("status", ""), + ) + ) + return symbols + + async def get_klines( + self, + symbol: str, + interval: str, + limit: int = 500, + category: str = "linear", + ) -> list[OHLCVBar]: + capped_limit = max(1, min(limit, 1000)) + response = await asyncio.to_thread( + self._client.get_kline, + category=category, + symbol=symbol, + interval=interval, + limit=capped_limit, + ) + self._ensure_success(response, symbol=symbol) + rows = response.get("result", {}).get("list", []) + bars = [self._parse_kline(row) for row in rows] + bars.sort(key=lambda bar: bar.time) + return bars + + async def get_ticker(self, symbol: str, category: str = "linear") -> Ticker: + response = await asyncio.to_thread( + self._client.get_tickers, + category=category, + symbol=symbol, + ) + self._ensure_success(response, symbol=symbol) + rows = response.get("result", {}).get("list", []) + if not rows: + raise BrokerNotFoundError(f"Symbol not found: {symbol}") + row = rows[0] + return Ticker( + symbol=row.get("symbol", symbol), + lastPrice=float(row.get("lastPrice", 0)), + price24hPcnt=float(row.get("price24hPcnt", 0)), + volume24h=float(row.get("volume24h", 0)), + highPrice24h=float(row.get("highPrice24h", 0)), + lowPrice24h=float(row.get("lowPrice24h", 0)), + ) + + def _parse_kline(self, row: list[Any]) -> OHLCVBar: + # Bybit list format: [startTime, open, high, low, close, volume, turnover] + start_ms = int(row[0]) + return OHLCVBar( + time=start_ms // 1000, + open=float(row[1]), + high=float(row[2]), + low=float(row[3]), + close=float(row[4]), + volume=float(row[5]), + ) + + def _ensure_success(self, response: dict[str, Any], symbol: str | None = None) -> None: + ret_code = response.get("retCode") + ret_msg = response.get("retMsg", "Bybit API error") + if ret_code == 0: + return + if ret_code in {10001, 10002, 10003, 10004, 10005, 10006, 10007, 10017}: + raise BrokerNotFoundError(ret_msg or f"Symbol not found: {symbol}") + raise BrokerUpstreamError(ret_msg) diff --git a/gateway/app/brokers/errors.py b/gateway/app/brokers/errors.py new file mode 100644 index 000000000..e127f8bf2 --- /dev/null +++ b/gateway/app/brokers/errors.py @@ -0,0 +1,17 @@ +"""Broker-related exceptions mapped to HTTP status codes in routers.""" + + +class BrokerError(Exception): + """Base broker error.""" + + +class BrokerNotFoundError(BrokerError): + """Unknown symbol or resource.""" + + +class BrokerUpstreamError(BrokerError): + """Upstream exchange API failure.""" + + +class BrokerRateLimitError(BrokerError): + """Rate limit exceeded.""" diff --git a/gateway/tests/test_bybit.py b/gateway/tests/test_bybit.py new file mode 100644 index 000000000..203e99a00 --- /dev/null +++ b/gateway/tests/test_bybit.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from app.brokers.bybit import BybitAdapter +from app.brokers.errors import BrokerNotFoundError, BrokerUpstreamError + + +@pytest.fixture +def adapter() -> BybitAdapter: + with patch("app.brokers.bybit.HTTP"): + return BybitAdapter(testnet=True, api_key="", api_secret="") + + +@pytest.mark.asyncio +async def test_get_symbols_filters_trading(adapter: BybitAdapter) -> None: + adapter._client.get_instruments_info = MagicMock( + return_value={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "baseCoin": "BTC", + "quoteCoin": "USDT", + "status": "Trading", + }, + { + "symbol": "OLDCOIN", + "baseCoin": "OLD", + "quoteCoin": "USDT", + "status": "Closed", + }, + ] + }, + } + ) + symbols = await adapter.get_symbols() + assert len(symbols) == 1 + assert symbols[0].symbol == "BTCUSDT" + + +@pytest.mark.asyncio +async def test_get_klines_sorted_ascending(adapter: BybitAdapter) -> None: + adapter._client.get_kline = MagicMock( + return_value={ + "retCode": 0, + "result": { + "list": [ + ["2000000", "2", "3", "1", "2.5", "100", "200"], + ["1000000", "1", "2", "0.5", "1.5", "50", "100"], + ] + }, + } + ) + bars = await adapter.get_klines("BTCUSDT", "60", 2) + assert len(bars) == 2 + assert bars[0].time == 1000 + assert bars[1].time == 2000 + assert bars[0].close == 1.5 + + +@pytest.mark.asyncio +async def test_get_ticker(adapter: BybitAdapter) -> None: + adapter._client.get_tickers = MagicMock( + return_value={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "lastPrice": "65000", + "price24hPcnt": "1.23", + "volume24h": "999", + "highPrice24h": "66000", + "lowPrice24h": "64000", + } + ] + }, + } + ) + ticker = await adapter.get_ticker("BTCUSDT") + assert ticker.symbol == "BTCUSDT" + assert ticker.lastPrice == 65000.0 + + +@pytest.mark.asyncio +async def test_get_ticker_not_found(adapter: BybitAdapter) -> None: + adapter._client.get_tickers = MagicMock( + return_value={"retCode": 0, "result": {"list": []}} + ) + with pytest.raises(BrokerNotFoundError): + await adapter.get_ticker("INVALID") + + +@pytest.mark.asyncio +async def test_upstream_error(adapter: BybitAdapter) -> None: + adapter._client.get_kline = MagicMock( + return_value={"retCode": 10016, "retMsg": "server error"} + ) + with pytest.raises(BrokerUpstreamError): + await adapter.get_klines("BTCUSDT", "60", 10) From a4b23a67326042304f2613bb002453edaf79d87d Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:40:34 +0300 Subject: [PATCH 07/16] feat(gateway): add health and market REST endpoints Co-authored-by: Cursor --- gateway/app/main.py | 5 +++ gateway/app/routers/.gitkeep | 0 gateway/app/routers/__init__.py | 4 ++ gateway/app/routers/health.py | 18 ++++++++ gateway/app/routers/market.py | 80 +++++++++++++++++++++++++++++++++ gateway/tests/.gitkeep | 0 gateway/tests/test_market.py | 51 +++++++++++++++++++++ 7 files changed, 158 insertions(+) delete mode 100644 gateway/app/routers/.gitkeep create mode 100644 gateway/app/routers/__init__.py create mode 100644 gateway/app/routers/health.py create mode 100644 gateway/app/routers/market.py delete mode 100644 gateway/tests/.gitkeep create mode 100644 gateway/tests/test_market.py diff --git a/gateway/app/main.py b/gateway/app/main.py index a36a16c5e..07f817a97 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -1,7 +1,9 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +import app.brokers.bybit # noqa: F401 — register broker adapters from app.config import settings +from app.routers import health, market def create_app() -> FastAPI: @@ -20,6 +22,9 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + app.include_router(health.router, prefix="/api/v1") + app.include_router(market.router, prefix="/api/v1") + @app.get("/") async def root() -> dict[str, str]: return {"service": "rdagent-gateway", "docs": "/docs"} diff --git a/gateway/app/routers/.gitkeep b/gateway/app/routers/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/gateway/app/routers/__init__.py b/gateway/app/routers/__init__.py new file mode 100644 index 000000000..048d0691b --- /dev/null +++ b/gateway/app/routers/__init__.py @@ -0,0 +1,4 @@ +import app.brokers.bybit # noqa: F401 — register BybitAdapter +from app.routers import health, market + +__all__ = ["health", "market"] diff --git a/gateway/app/routers/health.py b/gateway/app/routers/health.py new file mode 100644 index 000000000..ebdfa8852 --- /dev/null +++ b/gateway/app/routers/health.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from app.config import settings +from app.models.market import HealthResponse + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + from app.brokers.base import BROKER_REGISTRY + + return HealthResponse( + status="ok", + version=settings.app_version, + brokers=sorted(BROKER_REGISTRY.keys()), + testnet=settings.bybit_testnet, + ) diff --git a/gateway/app/routers/market.py b/gateway/app/routers/market.py new file mode 100644 index 000000000..ddfa92990 --- /dev/null +++ b/gateway/app/routers/market.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, HTTPException, Query + +from app.brokers.base import get_broker +from app.brokers.errors import ( + BrokerNotFoundError, + BrokerRateLimitError, + BrokerUpstreamError, +) +from app.models.market import KlinesResponse, SymbolsResponse, Ticker + +router = APIRouter(prefix="/market", tags=["market"]) + + +def _resolve_broker(broker_id: str): + try: + return get_broker(broker_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +def _handle_broker_errors(exc: Exception) -> None: + if isinstance(exc, BrokerNotFoundError): + raise HTTPException(status_code=404, detail=str(exc)) from exc + if isinstance(exc, BrokerRateLimitError): + raise HTTPException(status_code=429, detail=str(exc)) from exc + if isinstance(exc, BrokerUpstreamError): + raise HTTPException(status_code=502, detail=str(exc)) from exc + raise exc + + +@router.get("/symbols", response_model=SymbolsResponse) +async def list_symbols( + broker: str = Query(default="bybit"), + category: str = Query(default="linear"), +) -> SymbolsResponse: + adapter = _resolve_broker(broker) + try: + symbols = await adapter.get_symbols(category=category) + except Exception as exc: + _handle_broker_errors(exc) + return SymbolsResponse(broker=broker, symbols=symbols) + + +@router.get("/klines", response_model=KlinesResponse) +async def get_klines( + symbol: str = Query(..., min_length=1), + interval: str = Query(default="60"), + limit: int = Query(default=500, ge=1, le=1000), + broker: str = Query(default="bybit"), + category: str = Query(default="linear"), +) -> KlinesResponse: + adapter = _resolve_broker(broker) + try: + bars = await adapter.get_klines( + symbol=symbol, + interval=interval, + limit=limit, + category=category, + ) + except Exception as exc: + _handle_broker_errors(exc) + return KlinesResponse( + broker=broker, + symbol=symbol, + interval=interval, + bars=bars, + ) + + +@router.get("/ticker", response_model=Ticker) +async def get_ticker( + symbol: str = Query(..., min_length=1), + broker: str = Query(default="bybit"), + category: str = Query(default="linear"), +) -> Ticker: + adapter = _resolve_broker(broker) + try: + return await adapter.get_ticker(symbol=symbol, category=category) + except Exception as exc: + _handle_broker_errors(exc) diff --git a/gateway/tests/.gitkeep b/gateway/tests/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/gateway/tests/test_market.py b/gateway/tests/test_market.py new file mode 100644 index 000000000..47170c6c3 --- /dev/null +++ b/gateway/tests/test_market.py @@ -0,0 +1,51 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.models.market import OHLCVBar, Symbol, Ticker + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def test_health(client: TestClient) -> None: + response = client.get("/api/v1/health") + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ok" + assert "bybit" in payload["brokers"] + + +@patch("app.routers.market.get_broker") +def test_klines(mock_get_broker: AsyncMock, client: TestClient) -> None: + mock_adapter = AsyncMock() + mock_adapter.get_klines.return_value = [ + OHLCVBar(time=1000, open=1, high=2, low=0.5, close=1.5, volume=10) + ] + mock_get_broker.return_value = mock_adapter + + response = client.get( + "/api/v1/market/klines", + params={"symbol": "BTCUSDT", "interval": "60", "limit": 1}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["symbol"] == "BTCUSDT" + assert len(payload["bars"]) == 1 + assert payload["bars"][0]["time"] == 1000 + + +@patch("app.routers.market.get_broker") +def test_ticker_not_found(mock_get_broker: AsyncMock, client: TestClient) -> None: + from app.brokers.errors import BrokerNotFoundError + + mock_adapter = AsyncMock() + mock_adapter.get_ticker.side_effect = BrokerNotFoundError("missing") + mock_get_broker.return_value = mock_adapter + + response = client.get("/api/v1/market/ticker", params={"symbol": "BAD"}) + assert response.status_code == 404 From 79a8cf4012c4dd6f4fb2e80843e3a10b8075cbdf Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:41:48 +0300 Subject: [PATCH 08/16] feat(terminal): vite react scaffold with tailwind and shadcn Co-authored-by: Cursor --- terminal/index.html | 12 + terminal/package-lock.json | 3557 ++++++++++++++++++++++ terminal/package.json | 37 + terminal/src/App.tsx | 11 + terminal/src/app/.gitkeep | 0 terminal/src/app/providers.tsx | 17 + terminal/src/app/router.tsx | 9 + terminal/src/components/ui/.gitkeep | 0 terminal/src/components/ui/badge.tsx | 27 + terminal/src/components/ui/button.tsx | 36 + terminal/src/components/ui/select.tsx | 70 + terminal/src/components/ui/separator.tsx | 20 + terminal/src/index.css | 29 + terminal/src/lib/.gitkeep | 0 terminal/src/lib/utils.ts | 6 + terminal/src/main.tsx | 10 + terminal/src/pages/.gitkeep | 0 terminal/src/pages/CommandCenter.tsx | 13 + terminal/src/vite-env.d.ts | 1 + terminal/tsconfig.json | 24 + terminal/tsconfig.node.json | 10 + terminal/tsconfig.tsbuildinfo | 1 + terminal/vite.config.ts | 19 + 23 files changed, 3909 insertions(+) create mode 100644 terminal/index.html create mode 100644 terminal/package-lock.json create mode 100644 terminal/package.json create mode 100644 terminal/src/App.tsx delete mode 100644 terminal/src/app/.gitkeep create mode 100644 terminal/src/app/providers.tsx create mode 100644 terminal/src/app/router.tsx delete mode 100644 terminal/src/components/ui/.gitkeep create mode 100644 terminal/src/components/ui/badge.tsx create mode 100644 terminal/src/components/ui/button.tsx create mode 100644 terminal/src/components/ui/select.tsx create mode 100644 terminal/src/components/ui/separator.tsx create mode 100644 terminal/src/index.css delete mode 100644 terminal/src/lib/.gitkeep create mode 100644 terminal/src/lib/utils.ts create mode 100644 terminal/src/main.tsx delete mode 100644 terminal/src/pages/.gitkeep create mode 100644 terminal/src/pages/CommandCenter.tsx create mode 100644 terminal/src/vite-env.d.ts create mode 100644 terminal/tsconfig.json create mode 100644 terminal/tsconfig.node.json create mode 100644 terminal/tsconfig.tsbuildinfo create mode 100644 terminal/vite.config.ts diff --git a/terminal/index.html b/terminal/index.html new file mode 100644 index 000000000..3083114d0 --- /dev/null +++ b/terminal/index.html @@ -0,0 +1,12 @@ + + + + + + RD-Agent Terminal + + +
+ + + diff --git a/terminal/package-lock.json b/terminal/package-lock.json new file mode 100644 index 000000000..4a203b1d6 --- /dev/null +++ b/terminal/package-lock.json @@ -0,0 +1,3557 @@ +{ + "name": "rdagent-terminal", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rdagent-terminal", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.90.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lightweight-charts": "^5.0.9", + "lucide-react": "^0.544.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-grid-layout": "^1.5.2", + "react-router-dom": "^7.9.1", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.13", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@types/react-grid-layout": "^1.3.5", + "@vitejs/plugin-react": "^5.0.3", + "tailwindcss": "^4.1.13", + "typescript": "~5.9.2", + "vite": "^7.1.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "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/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "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.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz", + "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/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/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "devOptional": 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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "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/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, + "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/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/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/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightweight-charts": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz", + "integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, + "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/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/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.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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": "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/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/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz", + "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "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" + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/terminal/package.json b/terminal/package.json new file mode 100644 index 000000000..e63c5fdc4 --- /dev/null +++ b/terminal/package.json @@ -0,0 +1,37 @@ +{ + "name": "rdagent-terminal", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.90.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lightweight-charts": "^5.0.9", + "lucide-react": "^0.544.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-grid-layout": "^1.5.2", + "react-router-dom": "^7.9.1", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.13", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@types/react-grid-layout": "^1.3.5", + "@vitejs/plugin-react": "^5.0.3", + "tailwindcss": "^4.1.13", + "typescript": "~5.9.2", + "vite": "^7.1.7" + } +} diff --git a/terminal/src/App.tsx b/terminal/src/App.tsx new file mode 100644 index 000000000..0547400c8 --- /dev/null +++ b/terminal/src/App.tsx @@ -0,0 +1,11 @@ +import { RouterProvider } from "react-router-dom"; +import { AppProviders } from "@/app/providers"; +import { router } from "@/app/router"; + +export default function App() { + return ( + + + + ); +} diff --git a/terminal/src/app/.gitkeep b/terminal/src/app/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/terminal/src/app/providers.tsx b/terminal/src/app/providers.tsx new file mode 100644 index 000000000..524244912 --- /dev/null +++ b/terminal/src/app/providers.tsx @@ -0,0 +1,17 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + {children} + ); +} diff --git a/terminal/src/app/router.tsx b/terminal/src/app/router.tsx new file mode 100644 index 000000000..1bd172412 --- /dev/null +++ b/terminal/src/app/router.tsx @@ -0,0 +1,9 @@ +import { createBrowserRouter } from "react-router-dom"; +import CommandCenter from "@/pages/CommandCenter"; + +export const router = createBrowserRouter([ + { + path: "/", + element: , + }, +]); diff --git a/terminal/src/components/ui/.gitkeep b/terminal/src/components/ui/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/terminal/src/components/ui/badge.tsx b/terminal/src/components/ui/badge.tsx new file mode 100644 index 000000000..3cfce283c --- /dev/null +++ b/terminal/src/components/ui/badge.tsx @@ -0,0 +1,27 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium", + { + variants: { + variant: { + default: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-muted)]", + accent: "border-amber-500/30 bg-amber-500/10 text-amber-300", + success: "border-green-500/30 bg-green-500/10 text-green-300", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export function Badge({ + className, + variant, + ...props +}: React.HTMLAttributes & VariantProps) { + return
; +} diff --git a/terminal/src/components/ui/button.tsx b/terminal/src/components/ui/button.tsx new file mode 100644 index 000000000..02e07ba39 --- /dev/null +++ b/terminal/src/components/ui/button.tsx @@ -0,0 +1,36 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-[var(--color-accent)] text-black hover:brightness-110", + outline: "border border-[var(--color-border)] bg-transparent hover:bg-[var(--color-surface)]", + ghost: "hover:bg-[var(--color-surface)]", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 px-3", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { + const Comp = asChild ? Slot : "button"; + return ; +} diff --git a/terminal/src/components/ui/select.tsx b/terminal/src/components/ui/select.tsx new file mode 100644 index 000000000..b8dec6f37 --- /dev/null +++ b/terminal/src/components/ui/select.tsx @@ -0,0 +1,70 @@ +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export const Select = SelectPrimitive.Root; +export const SelectValue = SelectPrimitive.Value; + +export function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + + + ); +} + +export function SelectContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + ); +} + +export function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} diff --git a/terminal/src/components/ui/separator.tsx b/terminal/src/components/ui/separator.tsx new file mode 100644 index 000000000..a34684251 --- /dev/null +++ b/terminal/src/components/ui/separator.tsx @@ -0,0 +1,20 @@ +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/lib/utils"; + +export function Separator({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ); +} diff --git a/terminal/src/index.css b/terminal/src/index.css new file mode 100644 index 000000000..edeaabf8f --- /dev/null +++ b/terminal/src/index.css @@ -0,0 +1,29 @@ +@import "tailwindcss"; + +@theme { + --color-background: #0a0e17; + --color-surface: #111827; + --color-border: #1f2937; + --color-accent: #f59e0b; + --color-positive: #22c55e; + --color-negative: #ef4444; + --color-muted: #9ca3af; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background: var(--color-background); + color: #e5e7eb; + font-family: Inter, system-ui, sans-serif; +} + +.tabular-nums { + font-variant-numeric: tabular-nums; +} diff --git a/terminal/src/lib/.gitkeep b/terminal/src/lib/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/terminal/src/lib/utils.ts b/terminal/src/lib/utils.ts new file mode 100644 index 000000000..365058ceb --- /dev/null +++ b/terminal/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/terminal/src/main.tsx b/terminal/src/main.tsx new file mode 100644 index 000000000..c2a145c6d --- /dev/null +++ b/terminal/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/terminal/src/pages/.gitkeep b/terminal/src/pages/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/terminal/src/pages/CommandCenter.tsx b/terminal/src/pages/CommandCenter.tsx new file mode 100644 index 000000000..bc86fc092 --- /dev/null +++ b/terminal/src/pages/CommandCenter.tsx @@ -0,0 +1,13 @@ +export default function CommandCenter() { + return ( +
+
+

RD-Agent Terminal

+

Market view scaffold — Phase 1

+
+
+ Command Center loading... +
+
+ ); +} diff --git a/terminal/src/vite-env.d.ts b/terminal/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/terminal/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/terminal/tsconfig.json b/terminal/tsconfig.json new file mode 100644 index 000000000..4e0fd9a4d --- /dev/null +++ b/terminal/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/terminal/tsconfig.node.json b/terminal/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/terminal/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/terminal/tsconfig.tsbuildinfo b/terminal/tsconfig.tsbuildinfo new file mode 100644 index 000000000..743abfc2f --- /dev/null +++ b/terminal/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/app/providers.tsx","./src/app/router.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/lib/utils.ts","./src/pages/commandcenter.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/terminal/vite.config.ts b/terminal/vite.config.ts new file mode 100644 index 000000000..7aba96d1a --- /dev/null +++ b/terminal/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 5173, + proxy: { + "/api": "http://localhost:6900", + }, + }, +}); From 24ab271939758e5530c8be1c5d6d1a5b85adacfe Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:41:57 +0300 Subject: [PATCH 09/16] feat(terminal): typed API client and market data hooks Co-authored-by: Cursor --- terminal/src/hooks/useMarket.ts | 36 +++++++++++++++++++ terminal/src/lib/api.ts | 49 ++++++++++++++++++++++++++ terminal/src/lib/format.ts | 21 +++++++++++ terminal/src/lib/types.ts | 62 +++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 terminal/src/hooks/useMarket.ts create mode 100644 terminal/src/lib/api.ts create mode 100644 terminal/src/lib/format.ts create mode 100644 terminal/src/lib/types.ts diff --git a/terminal/src/hooks/useMarket.ts b/terminal/src/hooks/useMarket.ts new file mode 100644 index 000000000..a1f43268b --- /dev/null +++ b/terminal/src/hooks/useMarket.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchHealth, fetchKlines, fetchSymbols, fetchTicker } from "@/lib/api"; + +export function useHealth() { + return useQuery({ + queryKey: ["health"], + queryFn: fetchHealth, + refetchInterval: 30_000, + }); +} + +export function useSymbols(broker = "bybit") { + return useQuery({ + queryKey: ["symbols", broker], + queryFn: () => fetchSymbols(broker), + staleTime: 60 * 60 * 1000, + }); +} + +export function useKlines(symbol: string, interval: string, limit = 500, broker = "bybit") { + return useQuery({ + queryKey: ["klines", broker, symbol, interval, limit], + queryFn: () => fetchKlines(symbol, interval, limit, broker), + enabled: Boolean(symbol && interval), + }); +} + +export function useTicker(symbol: string, broker = "bybit") { + return useQuery({ + queryKey: ["ticker", broker, symbol], + queryFn: () => fetchTicker(symbol, broker), + enabled: Boolean(symbol), + staleTime: 5_000, + refetchInterval: 5_000, + }); +} diff --git a/terminal/src/lib/api.ts b/terminal/src/lib/api.ts new file mode 100644 index 000000000..3c8d67b15 --- /dev/null +++ b/terminal/src/lib/api.ts @@ -0,0 +1,49 @@ +import { ApiError, type HealthResponse, type KlinesResponse, type SymbolsResponse, type Ticker } from "@/lib/types"; + +const BASE_URL = import.meta.env.VITE_GATEWAY_URL ?? ""; + +async function request(path: string): Promise { + const response = await fetch(`${BASE_URL}${path}`); + if (!response.ok) { + let message = `Request failed: ${response.status}`; + try { + const payload = (await response.json()) as { detail?: string }; + if payload.detail) message = payload.detail; + } catch { + // ignore parse errors + } + throw new ApiError(message, response.status); + } + return (await response.json()) as T; +} + +export function fetchHealth(): Promise { + return request("/api/v1/health"); +} + +export function fetchSymbols(broker = "bybit", category = "linear"): Promise { + const params = new URLSearchParams({ broker, category }); + return request(`/api/v1/market/symbols?${params.toString()}`); +} + +export function fetchKlines( + symbol: string, + interval: string, + limit = 500, + broker = "bybit", + category = "linear", +): Promise { + const params = new URLSearchParams({ + broker, + category, + symbol, + interval, + limit: String(limit), + }); + return request(`/api/v1/market/klines?${params.toString()}`); +} + +export function fetchTicker(symbol: string, broker = "bybit", category = "linear"): Promise { + const params = new URLSearchParams({ broker, category, symbol }); + return request(`/api/v1/market/ticker?${params.toString()}`); +} diff --git a/terminal/src/lib/format.ts b/terminal/src/lib/format.ts new file mode 100644 index 000000000..081abcdc2 --- /dev/null +++ b/terminal/src/lib/format.ts @@ -0,0 +1,21 @@ +export function formatPrice(value: number, digits = 2): string { + if (!Number.isFinite(value)) return "—"; + return value.toLocaleString(undefined, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +export function formatPercent(value: number, digits = 2): string { + if (!Number.isFinite(value)) return "—"; + const sign = value > 0 ? "+" : ""; + return `${sign}${value.toFixed(digits)}%`; +} + +export function formatVolume(value: number): string { + if (!Number.isFinite(value)) return "—"; + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(2)}K`; + return value.toFixed(2); +} diff --git a/terminal/src/lib/types.ts b/terminal/src/lib/types.ts new file mode 100644 index 000000000..f83e4e4c4 --- /dev/null +++ b/terminal/src/lib/types.ts @@ -0,0 +1,62 @@ +export interface OHLCVBar { + time: number; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +export interface SymbolInfo { + symbol: string; + baseCoin: string; + quoteCoin: string; + status: string; +} + +export interface Ticker { + symbol: string; + lastPrice: number; + price24hPcnt: number; + volume24h: number; + highPrice24h: number; + lowPrice24h: number; +} + +export interface HealthResponse { + status: string; + version: string; + brokers: string[]; + testnet: boolean; +} + +export interface SymbolsResponse { + broker: string; + symbols: SymbolInfo[]; +} + +export interface KlinesResponse { + broker: string; + symbol: string; + interval: string; + bars: OHLCVBar[]; +} + +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +export const INTERVAL_OPTIONS = [ + { label: "1m", value: "1" }, + { label: "5m", value: "5" }, + { label: "15m", value: "15" }, + { label: "1h", value: "60" }, + { label: "4h", value: "240" }, + { label: "1D", value: "D" }, +] as const; From 0832df6d691264bd502300e82343d2554f76aa8a Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:42:45 +0300 Subject: [PATCH 10/16] feat(terminal): candlestick chart with lightweight-charts Co-authored-by: Cursor --- terminal/package-lock.json | 1 + terminal/package.json | 1 + terminal/src/components/charts/.gitkeep | 0 .../components/charts/CandlestickChart.tsx | 131 ++++++++++++++++++ terminal/src/components/workspace/.gitkeep | 0 terminal/src/components/workspace/Panel.tsx | 17 +++ .../src/components/workspace/StatusBar.tsx | 35 +++++ .../components/workspace/WorkspaceShell.tsx | 69 +++++++++ terminal/src/lib/api.ts | 4 +- terminal/src/pages/CommandCenter.tsx | 120 +++++++++++++++- terminal/src/stores/.gitkeep | 0 terminal/src/stores/workspaceStore.ts | 40 ++++++ terminal/tsconfig.tsbuildinfo | 2 +- 13 files changed, 410 insertions(+), 10 deletions(-) delete mode 100644 terminal/src/components/charts/.gitkeep create mode 100644 terminal/src/components/charts/CandlestickChart.tsx delete mode 100644 terminal/src/components/workspace/.gitkeep create mode 100644 terminal/src/components/workspace/Panel.tsx create mode 100644 terminal/src/components/workspace/StatusBar.tsx create mode 100644 terminal/src/components/workspace/WorkspaceShell.tsx delete mode 100644 terminal/src/stores/.gitkeep create mode 100644 terminal/src/stores/workspaceStore.ts diff --git a/terminal/package-lock.json b/terminal/package-lock.json index 4a203b1d6..ea711c307 100644 --- a/terminal/package-lock.json +++ b/terminal/package-lock.json @@ -19,6 +19,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-grid-layout": "^1.5.2", + "react-resizable": "^3.2.0", "react-router-dom": "^7.9.1", "tailwind-merge": "^3.3.1", "zustand": "^5.0.8" diff --git a/terminal/package.json b/terminal/package.json index e63c5fdc4..8bd35ef7c 100644 --- a/terminal/package.json +++ b/terminal/package.json @@ -20,6 +20,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-grid-layout": "^1.5.2", + "react-resizable": "^3.2.0", "react-router-dom": "^7.9.1", "tailwind-merge": "^3.3.1", "zustand": "^5.0.8" diff --git a/terminal/src/components/charts/.gitkeep b/terminal/src/components/charts/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/terminal/src/components/charts/CandlestickChart.tsx b/terminal/src/components/charts/CandlestickChart.tsx new file mode 100644 index 000000000..dc864b667 --- /dev/null +++ b/terminal/src/components/charts/CandlestickChart.tsx @@ -0,0 +1,131 @@ +import { + CandlestickSeries, + ColorType, + createChart, + HistogramSeries, + type IChartApi, + type ISeriesApi, + type UTCTimestamp, +} from "lightweight-charts"; +import { useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import type { OHLCVBar } from "@/lib/types"; + +interface CandlestickChartProps { + bars: OHLCVBar[]; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +export function CandlestickChart({ bars, loading, error, onRetry }: CandlestickChartProps) { + const containerRef = useRef(null); + const chartRef = useRef(null); + const candleSeriesRef = useRef | null>(null); + const volumeSeriesRef = useRef | null>(null); + + useEffect(() => { + if (!containerRef.current) return; + + const chart = createChart(containerRef.current, { + layout: { + background: { type: ColorType.Solid, color: "#0a0e17" }, + textColor: "#9ca3af", + }, + grid: { + vertLines: { color: "#1f2937" }, + horzLines: { color: "#1f2937" }, + }, + rightPriceScale: { borderColor: "#1f2937" }, + timeScale: { borderColor: "#1f2937", timeVisible: true, secondsVisible: false }, + crosshair: { mode: 1 }, + autoSize: true, + }); + + const candleSeries = chart.addSeries(CandlestickSeries, { + upColor: "#22c55e", + downColor: "#ef4444", + borderVisible: false, + wickUpColor: "#22c55e", + wickDownColor: "#ef4444", + }); + + const volumeSeries = chart.addSeries(HistogramSeries, { + priceFormat: { type: "volume" }, + priceScaleId: "volume", + }); + + chart.priceScale("volume").applyOptions({ + scaleMargins: { top: 0.8, bottom: 0 }, + }); + + chartRef.current = chart; + candleSeriesRef.current = candleSeries; + volumeSeriesRef.current = volumeSeries; + + const observer = new ResizeObserver(() => { + if (containerRef.current) { + chart.applyOptions({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }); + } + }); + observer.observe(containerRef.current); + + return () => { + observer.disconnect(); + chart.remove(); + chartRef.current = null; + candleSeriesRef.current = null; + volumeSeriesRef.current = null; + }; + }, []); + + useEffect(() => { + if (!candleSeriesRef.current || !volumeSeriesRef.current) return; + + candleSeriesRef.current.setData( + bars.map((bar) => ({ + time: bar.time as UTCTimestamp, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + })), + ); + + volumeSeriesRef.current.setData( + bars.map((bar) => ({ + time: bar.time as UTCTimestamp, + value: bar.volume, + color: bar.close >= bar.open ? "rgba(34,197,94,0.5)" : "rgba(239,68,68,0.5)", + })), + ); + + chartRef.current?.timeScale().fitContent(); + }, [bars]); + + if (loading) { + return ( +
+ Loading chart... +
+ ); + } + + if (error) { + return ( +
+

{error}

+ {onRetry ? ( + + ) : null} +
+ ); + } + + return
; +} diff --git a/terminal/src/components/workspace/.gitkeep b/terminal/src/components/workspace/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/terminal/src/components/workspace/Panel.tsx b/terminal/src/components/workspace/Panel.tsx new file mode 100644 index 000000000..2c229c8af --- /dev/null +++ b/terminal/src/components/workspace/Panel.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +interface PanelProps { + title: string; + children: ReactNode; +} + +export function Panel({ title, children }: PanelProps) { + return ( +
+
+ {title} +
+
{children}
+
+ ); +} diff --git a/terminal/src/components/workspace/StatusBar.tsx b/terminal/src/components/workspace/StatusBar.tsx new file mode 100644 index 000000000..23971e5ac --- /dev/null +++ b/terminal/src/components/workspace/StatusBar.tsx @@ -0,0 +1,35 @@ +import { Badge } from "@/components/ui/badge"; +import { formatPercent, formatPrice } from "@/lib/format"; +import type { HealthResponse, Ticker } from "@/lib/types"; + +interface StatusBarProps { + health?: HealthResponse; + healthError?: boolean; + symbol: string; + ticker?: Ticker; +} + +export function StatusBar({ health, healthError, symbol, ticker }: StatusBarProps) { + const connected = !healthError && health?.status === "ok"; + + return ( +
+
+ Gateway: {connected ? "connected" : "disconnected"} + + Bybit {health?.testnet ? "testnet" : "mainnet"} + + {symbol} + {ticker ? ( + + {formatPrice(ticker.lastPrice)}{" "} + = 0 ? "text-green-400" : "text-red-400"}> + {formatPercent(ticker.price24hPcnt)} + + + ) : null} +
+ RD-Agent Terminal v0.1.0 +
+ ); +} diff --git a/terminal/src/components/workspace/WorkspaceShell.tsx b/terminal/src/components/workspace/WorkspaceShell.tsx new file mode 100644 index 000000000..fb20bf7b2 --- /dev/null +++ b/terminal/src/components/workspace/WorkspaceShell.tsx @@ -0,0 +1,69 @@ +import { useMemo } from "react"; +import GridLayout, { type Layout } from "react-grid-layout"; +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; +import { Panel } from "@/components/workspace/Panel"; +import { useWorkspaceStore } from "@/stores/workspaceStore"; + +interface WorkspaceShellProps { + chart: React.ReactNode; + ticker: React.ReactNode; +} + +export function WorkspaceShell({ chart, ticker }: WorkspaceShellProps) { + const { layout, setLayout } = useWorkspaceStore(); + + const onLayoutChange = (nextLayout: Layout[]) => { + setLayout(nextLayout); + }; + + const children = useMemo( + () => ({ + chart, + ticker, + agent: ( +
+ Agent Console — Phase 2 +
+ ), + execution: ( +
+ Execution Monitor — Phase 3 +
+ ), + }), + [chart, ticker], + ); + + return ( +
+ +
+ +
+ Drag panel +
+
{children.chart}
+
+
+
+ {children.ticker} +
+
+ {children.agent} +
+
+ {children.execution} +
+
+
+ ); +} diff --git a/terminal/src/lib/api.ts b/terminal/src/lib/api.ts index 3c8d67b15..855fe85e3 100644 --- a/terminal/src/lib/api.ts +++ b/terminal/src/lib/api.ts @@ -7,8 +7,8 @@ async function request(path: string): Promise { if (!response.ok) { let message = `Request failed: ${response.status}`; try { - const payload = (await response.json()) as { detail?: string }; - if payload.detail) message = payload.detail; + const payload = (await response.json()) as Record; + if (typeof payload.detail === "string") message = payload.detail; } catch { // ignore parse errors } diff --git a/terminal/src/pages/CommandCenter.tsx b/terminal/src/pages/CommandCenter.tsx index bc86fc092..d2efb8d22 100644 --- a/terminal/src/pages/CommandCenter.tsx +++ b/terminal/src/pages/CommandCenter.tsx @@ -1,13 +1,119 @@ +import { CandlestickChart } from "@/components/charts/CandlestickChart"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { StatusBar } from "@/components/workspace/StatusBar"; +import { WorkspaceShell } from "@/components/workspace/WorkspaceShell"; +import { useHealth, useKlines, useSymbols, useTicker } from "@/hooks/useMarket"; +import { formatPercent, formatPrice, formatVolume } from "@/lib/format"; +import { INTERVAL_OPTIONS } from "@/lib/types"; +import { useWorkspaceStore } from "@/stores/workspaceStore"; + export default function CommandCenter() { + const { activeSymbol, activeInterval, setActiveSymbol, setActiveInterval } = useWorkspaceStore(); + const healthQuery = useHealth(); + const symbolsQuery = useSymbols(); + const klinesQuery = useKlines(activeSymbol, activeInterval); + const tickerQuery = useTicker(activeSymbol); + + const symbolOptions = + symbolsQuery.data?.symbols.map((item) => item.symbol) ?? ["BTCUSDT", "ETHUSDT", "SOLUSDT"]; + + const klinesError = + klinesQuery.error instanceof Error ? klinesQuery.error.message : klinesQuery.isError ? "Failed to load klines" : null; + return ( -
-
-

RD-Agent Terminal

-

Market view scaffold — Phase 1

+
+
+
+

RD-Agent Terminal

+

Bybit market view • PR #1

+
+
+ + +
-
- Command Center loading... -
+ + void klinesQuery.refetch()} + /> + } + ticker={ + tickerQuery.isLoading ? ( +
Loading ticker...
+ ) : tickerQuery.data ? ( +
+
+
Last
+
{formatPrice(tickerQuery.data.lastPrice)}
+
+
+
24h Change
+
= 0 ? "text-green-400" : "text-red-400" + }`} + > + {formatPercent(tickerQuery.data.price24hPcnt)} +
+
+
+
24h High
+
{formatPrice(tickerQuery.data.highPrice24h)}
+
+
+
24h Low
+
{formatPrice(tickerQuery.data.lowPrice24h)}
+
+
+
24h Volume
+
{formatVolume(tickerQuery.data.volume24h)}
+
+
+ ) : ( +
Ticker unavailable
+ ) + } + /> + +
); } diff --git a/terminal/src/stores/.gitkeep b/terminal/src/stores/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/terminal/src/stores/workspaceStore.ts b/terminal/src/stores/workspaceStore.ts new file mode 100644 index 000000000..e5cdb271e --- /dev/null +++ b/terminal/src/stores/workspaceStore.ts @@ -0,0 +1,40 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { Layout } from "react-grid-layout"; + +const DEFAULT_LAYOUT: Layout[] = [ + { i: "chart", x: 0, y: 0, w: 8, h: 12, minW: 4, minH: 8 }, + { i: "ticker", x: 8, y: 0, w: 4, h: 4, minW: 3, minH: 3 }, + { i: "agent", x: 8, y: 4, w: 4, h: 4, minW: 3, minH: 3 }, + { i: "execution", x: 8, y: 8, w: 4, h: 4, minW: 3, minH: 3 }, +]; + +interface WorkspaceState { + layout: Layout[]; + activeSymbol: string; + activeInterval: string; + setLayout: (layout: Layout[]) => void; + setActiveSymbol: (symbol: string) => void; + setActiveInterval: (interval: string) => void; +} + +export const useWorkspaceStore = create()( + persist( + (set) => ({ + layout: DEFAULT_LAYOUT, + activeSymbol: "BTCUSDT", + activeInterval: "60", + setLayout: (layout) => set({ layout }), + setActiveSymbol: (activeSymbol) => set({ activeSymbol }), + setActiveInterval: (activeInterval) => set({ activeInterval }), + }), + { + name: "rdagent-terminal-workspace", + partialize: (state) => ({ + layout: state.layout, + activeSymbol: state.activeSymbol, + activeInterval: state.activeInterval, + }), + }, + ), +); diff --git a/terminal/tsconfig.tsbuildinfo b/terminal/tsconfig.tsbuildinfo index 743abfc2f..e23400c4a 100644 --- a/terminal/tsconfig.tsbuildinfo +++ b/terminal/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/app/providers.tsx","./src/app/router.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/lib/utils.ts","./src/pages/commandcenter.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/app/providers.tsx","./src/app/router.tsx","./src/components/charts/candlestickchart.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/workspace/panel.tsx","./src/components/workspace/statusbar.tsx","./src/components/workspace/workspaceshell.tsx","./src/hooks/usemarket.ts","./src/lib/api.ts","./src/lib/format.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/commandcenter.tsx","./src/stores/workspacestore.ts"],"version":"5.9.3"} \ No newline at end of file From a462f7ec35455da38502267674ea641843c61c9e Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 01:43:12 +0300 Subject: [PATCH 11/16] chore(terminal): docker compose and development guide Co-authored-by: Cursor --- README.md | 17 ++ docker-compose.terminal.yml | 17 ++ docs/terminal/DEVELOPMENT.md | 97 +++++++ docs/terminal/PROMPT_SEQUENCE.md | 485 +++++++++++++++++++++++++++++++ gateway/Dockerfile | 15 + 5 files changed, 631 insertions(+) create mode 100644 docker-compose.terminal.yml create mode 100644 docs/terminal/DEVELOPMENT.md create mode 100644 docs/terminal/PROMPT_SEQUENCE.md create mode 100644 gateway/Dockerfile diff --git a/README.md b/README.md index 57864c01a..979506571 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,23 @@ rdagent server_ui --port 19899 After that, open `http://127.0.0.1:19899` in your browser. +#### RD-Agent Terminal (PR #1) + +A new React terminal-style UI lives in `terminal/` with a FastAPI gateway in `gateway/` for Bybit market data and charts. + +See [docs/terminal/DEVELOPMENT.md](docs/terminal/DEVELOPMENT.md) for setup. Quick start: + +```sh +# Terminal 1 — gateway +cd gateway && pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 6900 --reload + +# Terminal 2 — frontend +cd terminal && npm install && npm run dev +``` + +Open `http://localhost:5173`. Legacy Vue UI in `web/` remains unchanged. + #### Common Notes Port `19899` is used in the examples above. Before starting either UI, check whether this port is already occupied. If it is, please change it to another available port. diff --git a/docker-compose.terminal.yml b/docker-compose.terminal.yml new file mode 100644 index 000000000..b88f0579d --- /dev/null +++ b/docker-compose.terminal.yml @@ -0,0 +1,17 @@ +services: + gateway: + build: + context: ./gateway + ports: + - "6900:6900" + env_file: + - .env + environment: + BYBIT_TESTNET: "${BYBIT_TESTNET:-true}" + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + profiles: + - cache diff --git a/docs/terminal/DEVELOPMENT.md b/docs/terminal/DEVELOPMENT.md new file mode 100644 index 000000000..724ad2c21 --- /dev/null +++ b/docs/terminal/DEVELOPMENT.md @@ -0,0 +1,97 @@ +# RD-Agent Terminal — Development Guide + +## Prerequisites + +| Tool | Version | +|------|---------| +| Node.js | 20+ | +| Python | 3.10+ | +| npm | 10+ | +| Docker Desktop | optional (gateway container) | + +## Quick Start (local) + +### 1. Gateway (FastAPI) + +```powershell +cd gateway +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 6900 --reload +``` + +Verify: + +```powershell +curl http://localhost:6900/api/v1/health +curl "http://localhost:6900/api/v1/market/klines?symbol=BTCUSDT&interval=60&limit=10" +``` + +OpenAPI docs: http://localhost:6900/docs + +### 2. Terminal (React) + +```powershell +cd terminal +npm install +npm run dev +``` + +Open: http://localhost:5173 + +Vite proxies `/api` to `http://localhost:6900`. + +## Environment + +Copy root `.env.example` to `.env` and set: + +```env +GATEWAY_PORT=6900 +BYBIT_TESTNET=true +BYBIT_API_KEY= +BYBIT_API_SECRET= +``` + +Public klines/tickers work **without** API keys on Bybit testnet. + +## Docker (gateway only) + +```powershell +docker compose -f docker-compose.terminal.yml up --build gateway +``` + +## Port Map + +| Service | Port | +|---------|------| +| Terminal (Vite) | 5173 | +| Gateway (FastAPI) | 6900 | +| Flask server_ui (legacy) | 19899 | + +## Windows + qlib (Phase 2+) + +RD-Agent quant scenarios (`fin_factor`, `fin_model`) use qlib via Docker/WSL2. +Terminal PR #1 does not modify qlib execution. + +- Use WSL2 Ubuntu for `local_qlib` Docker image +- Set `MODEL_CoSTEER_env_type=docker` in `.env` +- Mount `~/.qlib` for CN market data + +## Troubleshooting + +### CORS errors +Ensure gateway `cors_origins` includes `http://localhost:5173`. + +### Chart empty / API errors +1. Confirm gateway is running on port 6900 +2. Check `/api/v1/health` returns `"status": "ok"` +3. Test klines endpoint directly in browser + +### Bybit 502 errors +Upstream Bybit API may be rate-limited or unavailable. Retry after a few seconds. + +## Related Docs + +- [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) +- [PROMPT_SEQUENCE.md](./PROMPT_SEQUENCE.md) diff --git a/docs/terminal/PROMPT_SEQUENCE.md b/docs/terminal/PROMPT_SEQUENCE.md new file mode 100644 index 000000000..740a5c352 --- /dev/null +++ b/docs/terminal/PROMPT_SEQUENCE.md @@ -0,0 +1,485 @@ +# RD-Agent Terminal — Последовательность промптов (PR #1) + +> **Версия:** 1.0 +> **Дата:** 2026-05-23 +> **Scope:** PR #1 — scaffold terminal + gateway + Bybit klines + chart +> **План:** [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) +> **Rules:** `.cursor/rules/terminal-*.mdc`, `gateway-python.mdc` +> **Checkpoint:** `aa42a7b0` + +--- + +## Как использовать + +1. **Перед каждым промптом** — убедитесь, что Cursor rules активны (Project Rules в настройках). +2. **Выполняйте промпты строго по порядку** — каждый следующий зависит от предыдущего. +3. **После каждого промпта** — проверьте Gate (критерии перехода); не переходите, пока gate не пройден. +4. **Один промпт = один логический коммит** (или группа tightly related файлов). +5. **При отклонении от плана** — остановитесь и согласуйте; не расширяйте scope. + +### Шаблон invocation + +``` +@docs/terminal/IMPLEMENTATION_PLAN.md @docs/terminal/PROMPT_SEQUENCE.md +[текст промпта из секции N] +``` + +--- + +## Фаза 0 — Подготовка (выполнено) + +| # | Действие | Статус | +|---|----------|--------| +| 0.1 | Git checkpoint `aa42a7b0` | ✅ | +| 0.2 | IMPLEMENTATION_PLAN.md | ✅ | +| 0.3 | Cursor rules `.cursor/rules/` | ✅ | +| 0.4 | PROMPT_SEQUENCE.md (этот файл) | ✅ | + +--- + +## Промпт 1 — Branch & Scaffold + +### Цель +Создать ветку и каркас директорий без бизнес-логики. + +### Промпт + +``` +Контекст: RD-Agent Terminal PR #1. Следуй .cursor/rules/terminal-project-scope.mdc и docs/terminal/IMPLEMENTATION_PLAN.md §14.2. + +Задача: +1. Создай ветку feat/terminal-pr1-scaffold от текущего main. +2. Создай пустую структуру директорий: + - gateway/app/{brokers,routers,models,services} + - gateway/tests + - terminal/src/{app,pages,components/{workspace,charts,ui},lib,stores} +3. Обнови .gitignore: terminal/node_modules, terminal/dist, gateway/.venv, gateway/__pycache__ +4. НЕ создавай пока package.json, pyproject.toml, бизнес-код. +5. НЕ трогай rdagent/ и web/. + +Gate: git status чистый; директории существуют; rdagent/ и web/ без изменений. +Коммит: chore(terminal): scaffold directory structure for PR #1 +``` + +### Gate +- [ ] Branch `feat/terminal-pr1-scaffold` создан +- [ ] Директории на месте +- [ ] `.gitignore` обновлён +- [ ] `rdagent/`, `web/` не изменены + +--- + +## Промпт 2 — Gateway Config & Models + +### Цель +Конфигурация, Pydantic-модели, BrokerAdapter ABC. + +### Промпт + +``` +Контекст: PR #1 Epic 2. Rules: gateway-python.mdc, terminal-api-contract.mdc, terminal-security.mdc. + +Задача — gateway/ foundation: +1. gateway/pyproject.toml + requirements.txt (fastapi, uvicorn[standard], pybit, pydantic-settings, httpx, pytest) +2. gateway/app/config.py — Settings: gateway_host, gateway_port, cors_origins, bybit_testnet=True, bybit_api_key/secret +3. gateway/app/models/market.py — Symbol, OHLCVBar, Ticker, KlinesResponse, SymbolsResponse, HealthResponse +4. gateway/app/brokers/base.py — BrokerAdapter ABC + BrokerRegistry +5. gateway/app/services/risk_manager.py — stub class с docstrings (Phase 3), без logic +6. gateway/app/__init__.py, gateway/app/main.py — minimal FastAPI app + CORS, mount placeholder +7. Обнови .env.example: GATEWAY_PORT, BYBIT_TESTNET, BYBIT_API_KEY, BYBIT_API_SECRET + +Сверь модели с docs/terminal/IMPLEMENTATION_PLAN.md §12.1. +НЕ реализуй BybitAdapter и routers пока. + +Gate: pip install -r requirements.txt OK; python -c "from app.config import Settings" OK. +Коммит: feat(gateway): config, pydantic models, and broker adapter interface +``` + +### Gate +- [ ] Settings загружает env +- [ ] Модели соответствуют API contract +- [ ] BrokerAdapter ABC определён +- [ ] risk_manager — stub only + +--- + +## Промпт 3 — BybitAdapter + +### Цель +Полная реализация read-only Bybit market data. + +### Промпт + +``` +Контекст: PR #1 Epic 2.4. Rules: gateway-python.mdc, terminal-security.mdc. + +Задача — gateway/app/brokers/bybit.py: +1. BybitAdapter(BrokerAdapter) — pybit HTTP, category=linear, testnet from Settings +2. get_symbols() — get_instruments_info, filter status=Trading +3. get_klines(symbol, interval, limit) — get_kline, normalize OHLCVBar, time in seconds, sort asc +4. get_ticker(symbol) — get_tickers +5. Error handling: Bybit fail→502, bad symbol→404, rate limit→429 +6. Register in BrokerRegistry as "bybit" +7. gateway/tests/test_bybit.py — unit tests с mocked pybit (без live API в CI) + +НЕ добавляй place_order, positions, WebSocket. +BYBIT_TESTNET=true по умолчанию. + +Gate: pytest gateway/tests/test_bybit.py pass. +Коммит: feat(gateway): implement BybitAdapter for read-only market data +``` + +### Gate +- [ ] Все три метода реализованы +- [ ] Tests pass с mocks +- [ ] Нет order/trade methods + +--- + +## Промпт 4 — Gateway Routers & Health + +### Цель +REST API endpoints + OpenAPI. + +### Промпт + +``` +Контекст: PR #1 Epic 2.5. Rules: gateway-python.mdc, terminal-api-contract.mdc. + +Задача: +1. gateway/app/routers/health.py — GET /api/v1/health +2. gateway/app/routers/market.py — GET /api/v1/market/symbols, /klines, /ticker + - Query params: broker (default bybit), symbol, interval, limit, category + - Resolve broker via BrokerRegistry +3. gateway/app/main.py — include routers under /api/v1, OpenAPI /docs +4. gateway/tests/test_market.py — test health + klines schema (mocked broker) + +Запуск: uvicorn app.main:app --host 0.0.0.0 --port 6900 --reload + +Gate (manual): +- curl http://localhost:6900/api/v1/health → 200 +- curl "http://localhost:6900/api/v1/market/klines?symbol=BTCUSDT&interval=60&limit=10" → valid JSON bars +- http://localhost:6900/docs открывается + +Коммит: feat(gateway): add health and market REST endpoints +``` + +### Gate +- [ ] 4 endpoints работают +- [ ] OpenAPI docs доступны +- [ ] Tests pass + +--- + +## Промпт 5 — Terminal Scaffold & Theme + +### Цель +Vite + React + TS + Tailwind + shadcn + providers. + +### Промпт + +``` +Контекст: PR #1 Epic 3.1–3.2. Rules: terminal-react.mdc, terminal-project-scope.mdc. + +Задача — terminal/ scaffold: +1. npm create vite@latest (react-ts) в terminal/ +2. Dependencies: react-router-dom, @tanstack/react-query, zustand, react-grid-layout, lightweight-charts +3. Tailwind v4 + shadcn/ui (button, select, badge, separator) +4. Dark fintech theme: bg #0a0e17, amber accent, tabular-nums +5. src/app/providers.tsx — QueryClientProvider +6. src/app/router.tsx — / → CommandCenter placeholder +7. vite.config.ts — proxy /api → http://localhost:6900 +8. terminal/.env.development — VITE_GATEWAY_URL=http://localhost:6900 + +НЕ реализуй chart и workspace пока — только «RD-Agent Terminal» placeholder page. + +Gate: npm run dev → http://localhost:5173 loads, dark theme visible. +Коммит: feat(terminal): vite react scaffold with tailwind and shadcn +``` + +### Gate +- [ ] Dev server starts +- [ ] Theme applied +- [ ] Proxy configured + +--- + +## Промпт 6 — API Client & Types + +### Цель +Typed client, TanStack Query hooks. + +### Промпт + +``` +Контекст: PR #1 Epic 3.3. Rules: terminal-api-contract.mdc. + +Задача: +1. terminal/src/lib/types.ts — mirror gateway models (OHLCVBar, Symbol, Ticker, etc.) +2. terminal/src/lib/api.ts — fetchHealth, fetchSymbols, fetchKlines, fetchTicker +3. terminal/src/lib/format.ts — price formatting, percent, volume abbrev +4. terminal/src/hooks/useMarket.ts — useKlines, useTicker, useSymbols (TanStack Query) + - useTicker staleTime: 5000 + - useKlines: refetch on symbol/interval change + +Field names MUST match gateway Pydantic models exactly. +Handle fetch errors with typed ApiError. + +Gate: with gateway running, hooks return data in React DevTools / test page. +Коммит: feat(terminal): typed API client and market data hooks +``` + +### Gate +- [ ] Types match contract +- [ ] Hooks fetch from gateway +- [ ] Error handling present + +--- + +## Промпт 7 — CandlestickChart Component + +### Цель +Lightweight Charts OHLC + volume, lifecycle-safe. + +### Промпт + +``` +Контекст: PR #1 Epic 3.5. Rules: terminal-react.mdc. Q4=A Lightweight Charts only. + +Задача — terminal/src/components/charts/CandlestickChart.tsx: +1. Props: bars: OHLCVBar[], loading, error, onRetry +2. createChart() on mount; candlestick + histogram (volume) series +3. Dark theme matching terminal (#0a0e17 background) +4. setData() when bars change; fitContent optional +5. ResizeObserver → chart.resize() +6. cleanup: remove chart on unmount +7. Loading skeleton, error state with retry button + +НЕ используй ECharts/Recharts для свечей. +НЕ добавляй signal markers (Phase 2). + +Gate: render with mock bars — chart visible, resize works, no console errors on unmount/remount. +Коммит: feat(terminal): candlestick chart with lightweight-charts +``` + +### Gate +- [ ] Chart renders mock data +- [ ] Resize works +- [ ] No memory leak on remount + +--- + +## Промпт 8 — Workspace Shell & Store + +### Цель +Grid layout, panels, persistence. + +### Промпт + +``` +Контекст: PR #1 Epic 3.4. Rules: terminal-react.mdc. + +Задача: +1. terminal/src/stores/workspaceStore.ts — layout, activeSymbol (BTCUSDT), activeInterval (60), save/load localStorage +2. terminal/src/components/workspace/Panel.tsx — title, collapse, children +3. terminal/src/components/workspace/StatusBar.tsx — gateway status, testnet badge, symbol, last price +4. terminal/src/components/workspace/WorkspaceShell.tsx — react-grid-layout default: + - chart (large), ticker-info, agent-placeholder, execution-placeholder +5. Placeholder text: "Agent Console — Phase 2", "Execution Monitor — Phase 3" + +Gate: panels drag/resize; layout persists after F5. +Коммит: feat(terminal): workspace shell with grid layout +``` + +### Gate +- [ ] Layout draggable/resizable +- [ ] localStorage persistence +- [ ] Placeholders visible + +--- + +## Промпт 9 — CommandCenter Integration + +### Цель +Собрать полную страницу: symbol/interval selectors + chart + ticker. + +### Промпт + +``` +Контекст: PR #1 Epic 3.6. Rules: all terminal rules. + +Задача — terminal/src/pages/CommandCenter.tsx: +1. Top bar: title, symbol Select (from useSymbols), interval Select (1m/5m/15m/1h/4h/1D) +2. WorkspaceShell with panels: + - Chart → CandlestickChart(useKlines) + - Ticker → 24h stats from useTicker + - Agent/Execution placeholders +3. StatusBar with gateway health check (useQuery fetchHealth) +4. Default: BTCUSDT, 1h + +End-to-end: gateway + terminal together. + +Gate (manual QA §14.6.2 items 1-6): +- Chart shows live BTCUSDT klines +- Switch ETHUSDT → chart updates +- Switch 1h→4h → chart updates +- Gateway down → error state + +Коммит: feat(terminal): command center with live bybit chart +``` + +### Gate +- [ ] E2E chart works with live gateway +- [ ] Symbol/interval switching works +- [ ] Error state on gateway down + +--- + +## Промпт 10 — Docker & DevOps + +### Цель +docker-compose, Dockerfile, DEVELOPMENT.md. + +### Промпт + +``` +Контекст: PR #1 Epic 4. Rules: terminal-security.mdc. + +Задача: +1. gateway/Dockerfile — python:3.11-slim, uvicorn +2. docker-compose.terminal.yml — gateway service port 6900, env_file .env; redis profile optional +3. docs/terminal/DEVELOPMENT.md: + - Prerequisites (Node 20+, Python 3.10+) + - Gateway setup (venv + uvicorn) + - Terminal setup (npm run dev) + - Bybit testnet key guide (optional for public klines) + - Windows notes, port map, troubleshooting CORS +4. README.md — секция "RD-Agent Terminal" со ссылкой на DEVELOPMENT.md + +Gate: docker compose -f docker-compose.terminal.yml up gateway — health OK. +Коммит: chore(terminal): docker compose and development guide +``` + +### Gate +- [ ] Docker gateway starts +- [ ] DEVELOPMENT.md complete +- [ ] README updated + +--- + +## Промпт 11 — Final QA & PR Prep + +### Цель +Полный QA, regression check, PR description. + +### Промпт + +``` +Контекст: PR #1 Epic 5–6. Rules: terminal-testing-dod.mdc. + +Задача: +1. Прогони gateway/tests/ — все pass +2. Manual QA checklist IMPLEMENTATION_PLAN.md §14.6.2 (items 1-9) +3. Verify rdagent/ и web/ — zero diff +4. Verify no secrets in git diff +5. Self-review: scope matches PR #1 only (§14.11 out-of-scope absent) +6. Prepare PR description: + - Title: feat(terminal): PR #1 scaffold — React terminal + FastAPI gateway + Bybit klines + - Summary bullets, test plan, screenshots note, breaking changes: none +7. Fix any issues found + +НЕ создавай PR автоматически без запроса пользователя. +НЕ push без запроса. + +Gate: all QA items pass; ready for review. +``` + +### Gate +- [ ] All tests pass +- [ ] Full manual QA pass +- [ ] No scope creep +- [ ] PR description drafted + +--- + +## Сводная таблица промптов + +| # | Промпт | Epic | Коммит prefix | Зависит от | +|---|--------|------|---------------|------------| +| 1 | Branch & Scaffold | 1 | chore | — | +| 2 | Gateway Config & Models | 2.1–2.3 | feat(gateway) | 1 | +| 3 | BybitAdapter | 2.4 | feat(gateway) | 2 | +| 4 | Gateway Routers | 2.5 | feat(gateway) | 3 | +| 5 | Terminal Scaffold | 3.1–3.2 | feat(terminal) | 1 | +| 6 | API Client & Types | 3.3 | feat(terminal) | 4, 5 | +| 7 | CandlestickChart | 3.5 | feat(terminal) | 5 | +| 8 | Workspace Shell | 3.4 | feat(terminal) | 5 | +| 9 | CommandCenter | 3.6 | feat(terminal) | 4, 6, 7, 8 | +| 10 | Docker & DevOps | 4 | chore | 4, 9 | +| 11 | Final QA & PR Prep | 5–6 | fix/chore | 10 | + +**Параллелизация:** Промпты 5–7 могут начаться после 4 (не зависят от 9). Оптимально: 1→2→3→4, параллельно 5→7→8, затем 6→9→10→11. + +--- + +## Anti-patterns — STOP if agent suggests + +| Anti-pattern | Правильное действие | +|--------------|---------------------| +| Modify `rdagent/scenarios/qlib/` | Out of scope PR #1 | +| Modify `web/` Vue app | Out of scope PR #1 | +| Add Flask proxy now | Phase 2 | +| Implement place_order | Phase 3 | +| Use ECharts for candles | Lightweight Charts only | +| Eager Redis/Celery | Optional Phase 2 | +| Full auth system | Phase 3 | +| Tiger adapter | Phase 4 | +| Rewrite IMPLEMENTATION_PLAN mid-PR | Update only if API contract changes | + +--- + +## Phase 2+ — Preview prompts (не выполнять сейчас) + +
+Phase 2 — Agent Console (reference) + +``` +Bridge Flask server_ui via gateway/app/services/agent_bridge.py. +WebSocket /ws/agent/trace/{id}. Migrate Playground flow to AgentConsole.tsx. +qlib_reader for ret.pkl and qlib_res.csv. Deprecate Vue agent views. +``` +
+ +
+Phase 3 — Execution + Risk (reference) + +``` +Order ticket UI. RiskManager enforcement. PaperAdapter. +POST /execution/orders with manual approval gate. Bybit WS for live P&L. +``` +
+ +
+Phase 4 — Tiger + Multi-market (reference) + +``` +TigerAdapter for US/HK/CN equities. OpenBB optional widgets. +``` +
+ +--- + +## Cursor Rules Index + +| Rule file | Scope | alwaysApply | +|-----------|-------|-------------| +| `terminal-project-scope.mdc` | Global scope & boundaries | ✅ | +| `gateway-python.mdc` | `gateway/**` | — | +| `terminal-react.mdc` | `terminal/**` | — | +| `terminal-api-contract.mdc` | API models & lib | — | +| `terminal-security.mdc` | Secrets, testnet | — | +| `terminal-testing-dod.mdc` | QA, commits | — | + +--- + +*Обновляйте версию при добавлении Phase 2+ prompt sequences.* diff --git a/gateway/Dockerfile b/gateway/Dockerfile new file mode 100644 index 000000000..f6169f144 --- /dev/null +++ b/gateway/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 6900 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "6900"] From 8166c6cf787671b6e256beb950ae855976438d6d Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 02:22:50 +0300 Subject: [PATCH 12/16] docs(terminal): phase 2 architecture and prompt sequence Co-authored-by: Cursor --- docs/terminal/PHASE2_IMPLEMENTATION_PLAN.md | 259 ++++++++++++++++++++ docs/terminal/PROMPT_SEQUENCE_PHASE2.md | 110 +++++++++ 2 files changed, 369 insertions(+) create mode 100644 docs/terminal/PHASE2_IMPLEMENTATION_PLAN.md create mode 100644 docs/terminal/PROMPT_SEQUENCE_PHASE2.md diff --git a/docs/terminal/PHASE2_IMPLEMENTATION_PLAN.md b/docs/terminal/PHASE2_IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..f41844c6f --- /dev/null +++ b/docs/terminal/PHASE2_IMPLEMENTATION_PLAN.md @@ -0,0 +1,259 @@ +# RD-Agent Terminal — Phase 2: Agent Console + Research Lab + +> **Версия:** 1.0 +> **Дата:** 2026-05-23 +> **Предусловие:** Phase 1 (PR #1) завершён на ветке `feat/terminal-pr1-scaffold` +> **Branch:** `feat/terminal-phase2-agent-research` + +--- + +## 1. Executive Summary + +Phase 2 соединяет **RD-Agent LLM loop** с React Terminal и добавляет **Research Lab** для qlib-метрик — без изменений `rdagent/` core и `web/` Vue. + +| Зона | Phase 1 | Phase 2 | +|------|---------|---------| +| Market Chart (Bybit) | ✅ | ✅ сохраняется | +| Agent Console | placeholder | **live trace, run/stop, scenarios** | +| Research Lab | placeholder | **metrics, equity curve, loop table** | +| Execution | placeholder | placeholder (Phase 3) | + +**Ключевое архитектурное решение Phase 2:** встроить agent orchestration в **gateway** (порт 6900), а не требовать отдельный Flask `server_ui`. WebStorage `/receive` обслуживается gateway для совместимости с rdagent logging. + +--- + +## 2. As-Is после Phase 1 + +``` +terminal/ → CommandCenter, Bybit chart, workspace +gateway/ → FastAPI, BybitAdapter, /api/v1/market/* +rdagent/ → RDAgentTask в Flask app.py (19899), FileStorage traces +web/ → Vue Playground (legacy) +``` + +**Trace flow (Flask today):** +``` +POST /upload → RDAgentTask subprocess → WebStorage POST /receive → task.messages +POST /trace → incremental poll → Vue PlaygroundPage +``` + +**Qlib artifacts в trace:** +| Tag / source | Content | +|--------------|---------| +| `feedback.metric` | `result` JSON (IC, ARR, MDD, …) | +| `feedback.hypothesis_feedback` | decision, reason, hypothesis | +| `feedback.return_chart` | Plotly HTML (legacy) | +| Pickle: `Quantitative Backtesting Chart` | pandas DataFrame (ret report) | +| Workspace files | `qlib_res.csv`, `ret.pkl` | + +--- + +## 3. To-Be Architecture (Phase 2) + +```mermaid +flowchart TB + subgraph Terminal + CC[CommandCenter] + AC[AgentConsole] + RL[ResearchLab] + CH[CandlestickChart + markers] + end + + subgraph Gateway6900 + AR[agent_runner] + TR[trace_reader] + QR[qlib_reader] + WS["/ws/agent/trace"] + REC["/receive WebStorage"] + end + + subgraph RDAgent + Loop[fin_factor | fin_model | fin_quant] + FS[FileStorage pickles] + end + + AC -->|REST + WS| Gateway6900 + RL -->|REST| QR + CC --> AC + CC --> RL + AR --> Loop + Loop -->|WebStorage| REC + TR --> FS + QR --> TR + CH -->|overlay| QR +``` + +--- + +## 4. Scope + +### 4.1 In Scope + +- Native agent runner в gateway (порт RDAgentTask, без обязательного Flask) +- `POST /api/v1/agent/run`, `GET /traces`, `GET /trace/{id}`, `POST /control`, `POST /user-interaction/submit` +- `POST /receive` — WebStorage compat +- `WS /ws/agent/trace/{trace_id}` — push новых сообщений +- `GET /api/v1/research/experiments`, `/research/{trace_id}/metrics`, `/research/{trace_id}/returns` +- Terminal: AgentConsole, ResearchLab, navigation, Recharts analytics +- Signal overlay markers на Lightweight Charts из returns (упрощённо: rebalance points) +- Docs + Phase 2 prompt sequence + +### 4.2 Out of Scope (Phase 3+) + +- Order placement / Bybit execution +- Full Vue removal (только deprecation notice в docs) +- Data Science scenario в terminal (Streamlit only) +- Auth / multi-user +- Изменения `rdagent/`, `web/` + +--- + +## 5. API Specification (Phase 2) + +### 5.1 Agent + +``` +GET /api/v1/agent/scenarios +POST /api/v1/agent/run multipart: scenario, loops, all_duration, files[] +GET /api/v1/agent/traces +GET /api/v1/agent/trace/{id}?offset=0&limit=50 +POST /api/v1/agent/control { id, action: "stop" } +POST /api/v1/agent/user-interaction/submit { id, payload } +WS /ws/agent/trace/{trace_id} +POST /receive WebStorage (rdagent compat) +``` + +### 5.2 Research + +``` +GET /api/v1/research/experiments + → [{ traceId, scenario, traceName, loopCount, lastTimestamp }] + +GET /api/v1/research/{trace_id}/metrics + → { loops: [{ loopId, metrics: {...}, decision, hypothesis }] } + +GET /api/v1/research/{trace_id}/returns?loop_id=0 + → { points: [{ time, bench, strategy, excess }], markers: [{ time, type }] } +``` + +--- + +## 6. Gateway Modules + +``` +gateway/app/ + services/ + agent_runner.py # RDAgentTask, processes dict, scenario map + trace_reader.py # FileStorage → normalized messages + qlib_reader.py # metrics + returns from trace + routers/ + agent.py + research.py + ws.py + models/ + agent.py + research.py +``` + +**Repo path bootstrap:** `sys.path.insert(0, repo_root)` в `main.py` для импорта `rdagent.*`. + +**New dependencies:** `pandas`, `aiofiles` (optional) + +--- + +## 7. Terminal Modules + +``` +terminal/src/ + pages/ + AgentConsole.tsx + ResearchLab.tsx + components/agent/ + ScenarioPicker.tsx + LoopTimeline.tsx + TraceMessageList.tsx + components/research/ + MetricsTable.tsx + EquityCurveChart.tsx + hooks/ + useAgent.ts + useAgentTrace.ts # WebSocket + useResearch.ts + lib/ + scenarios.ts + agentApi.ts +``` + +**Router:** +- `/` — CommandCenter (tabs: Market | Agent | Research) +- `/agent` — AgentConsole full page +- `/research` — ResearchLab + +--- + +## 8. Epics & Tasks + +### Epic 1 — Gateway Agent Runner (3d) +- 1.1 Config: `trace_folder`, `ui_server_port`, `workspace_path` +- 1.2 Port `RDAgentTask` + scenario mapping from Flask +- 1.3 `/receive`, load persisted traces on startup +- 1.4 Agent REST routes + tests + +### Epic 2 — WebSocket Trace (1d) +- 2.1 WS manager, subscribe by trace_id +- 2.2 Push on new /receive messages + END detection + +### Epic 3 — Qlib Research Reader (2d) +- 3.1 `trace_reader` via FileStorage + WebStorage._obj_to_json +- 3.2 Extract feedback.metric, hypothesis, ret DataFrame +- 3.3 Research REST routes + tests + +### Epic 4 — Terminal Agent UI (3d) +- 4.1 scenarios.ts, agentApi, useAgent hooks +- 4.2 AgentConsole: scenario select, run, stop, live timeline +- 4.3 WebSocket integration + +### Epic 5 — Terminal Research UI (2d) +- 5.1 MetricsTable, EquityCurveChart (Recharts) +- 5.2 ResearchLab page, trace picker +- 5.3 Chart markers overlay + +### Epic 6 — Integration & Docs (1d) +- 6.1 CommandCenter navigation tabs +- 6.2 DEVELOPMENT.md update, Vue deprecation note +- 6.3 QA checklist + +--- + +## 9. Definition of Done + +- [ ] Agent run fin_factor from terminal (loops=1) shows live trace +- [ ] Stop works via API +- [ ] Historical trace loads metrics + equity curve +- [ ] WebSocket delivers messages without polling +- [ ] Bybit chart still works (no regression) +- [ ] `rdagent/`, `web/` zero diff +- [ ] Gateway tests pass + +--- + +## 10. Risk & Mitigations + +| Risk | Mitigation | +|------|------------| +| rdagent import fails in gateway | sys.path + document `pip install -e .` from repo root | +| Long agent runs block gateway | subprocess (existing RDAgentTask pattern) | +| ret.pkl path unknown | Parse from trace pickles, not filesystem scan | +| Windows qlib docker | Document WSL2; agent UI works without local qlib run | + +--- + +## 11. Rollback + +Checkpoint before Phase 2: last commit on `feat/terminal-pr1-scaffold` (`a462f7ec`). + +```bash +git checkout feat/terminal-pr1-scaffold +# or +git checkout -b rollback-phase1 a462f7ec +``` diff --git a/docs/terminal/PROMPT_SEQUENCE_PHASE2.md b/docs/terminal/PROMPT_SEQUENCE_PHASE2.md new file mode 100644 index 000000000..d6841674e --- /dev/null +++ b/docs/terminal/PROMPT_SEQUENCE_PHASE2.md @@ -0,0 +1,110 @@ +# RD-Agent Terminal — Phase 2 Prompt Sequence + +> **Plan:** [PHASE2_IMPLEMENTATION_PLAN.md](./PHASE2_IMPLEMENTATION_PLAN.md) +> **Branch:** `feat/terminal-phase2-agent-research` +> **Base:** `feat/terminal-pr1-scaffold` @ `a462f7ec` + +--- + +## Prompt P2-1 — Branch & Gateway Config + +``` +Создай ветку feat/terminal-phase2-agent-research от feat/terminal-pr1-scaffold. +Расширь gateway/app/config.py: trace_folder, ui_server_port (=gateway_port), workspace_path, repo_root. +Добавь sys.path bootstrap в main.py для импорта rdagent. +Обнови .env.example: UI_TRACE_FOLDER, GATEWAY_PORT. +Коммит: chore(terminal): phase 2 branch and gateway config +``` + +## Prompt P2-2 — Agent Runner Service + +``` +Реализуй gateway/app/services/agent_runner.py — порт RDAgentTask из rdagent/log/server/app.py: +- scenario → target_name mapping (Finance Data Building → fin_factor, etc.) +- in-memory processes, /receive append, load persisted traces +- БЕЗ изменений rdagent/ +Коммит: feat(gateway): native agent runner service +``` + +## Prompt P2-3 — Agent REST + /receive + +``` +gateway/app/models/agent.py, routers/agent.py: +GET /api/v1/agent/scenarios, POST /run (multipart), GET /traces, GET /trace/{id}, POST /control, POST /user-interaction/submit +POST /receive (WebStorage compat) +Tests: test_agent.py +Коммит: feat(gateway): agent REST endpoints +``` + +## Prompt P2-4 — WebSocket Trace + +``` +gateway/app/routers/ws.py — WS /ws/agent/trace/{trace_id} +Push new messages from agent_runner, detect END tag +Коммит: feat(gateway): websocket agent trace streaming +``` + +## Prompt P2-5 — Qlib Research Reader + +``` +gateway/app/services/trace_reader.py + qlib_reader.py + routers/research.py +GET /api/v1/research/experiments, /research/{id}/metrics, /research/{id}/returns +Tests: test_research.py +Коммит: feat(gateway): qlib research reader API +``` + +## Prompt P2-6 — Terminal Agent API & Hooks + +``` +terminal/src/lib/scenarios.ts, agentApi.ts, hooks/useAgent.ts, useAgentTrace.ts (WebSocket) +Коммит: feat(terminal): agent API client and websocket hook +``` + +## Prompt P2-7 — AgentConsole UI + +``` +terminal/src/components/agent/*, pages/AgentConsole.tsx +Scenario picker, run/stop, LoopTimeline, TraceMessageList +Коммит: feat(terminal): agent console UI +``` + +## Prompt P2-8 — Research Lab UI + +``` +recharts dependency, components/research/*, pages/ResearchLab.tsx, hooks/useResearch.ts +MetricsTable, EquityCurveChart +Коммit: feat(terminal): research lab UI +``` + +## Prompt P2-9 — Integration & Overlay + +``` +CommandCenter tabs (Market | Agent | Research), chart markers from returns API +Router updates, DEVELOPMENT.md Phase 2 section +Коммит: feat(terminal): integrate agent and research into command center +``` + +## Prompt P2-10 — QA + +``` +pytest gateway/tests/, npm run build, manual checklist PHASE2 §9 +Verify rdagent/ web/ unchanged +Коммит: docs(terminal): phase 2 completion notes (if needed) +``` + +--- + +## Gate Matrix + +| Prompt | Gate | +|--------|------| +| P2-1 | Settings load trace_folder | +| P2-2 | Process starts fin_factor subprocess | +| P2-3 | POST /run returns trace id | +| P2-4 | WS receives messages | +| P2-5 | GET /metrics returns loops array | +| P2-6 | Hooks compile | +| P2-7 | AgentConsole renders | +| P2-8 | Equity curve renders mock/live data | +| P2-9 | Navigation works, chart overlay | +| P2-10 | All tests pass | From c1ef668495130841395fbb42a225e3180c6675fb Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 02:28:17 +0300 Subject: [PATCH 13/16] feat(gateway): phase 2 agent runner, websocket trace, and research API Co-authored-by: Cursor --- .env.example | 1 + gateway/app/config.py | 16 +- gateway/app/main.py | 24 +- gateway/app/models/agent.py | 31 ++ gateway/app/models/research.py | 43 +++ gateway/app/routers/__init__.py | 4 +- gateway/app/routers/agent.py | 81 +++++ gateway/app/routers/research.py | 29 ++ gateway/app/services/agent_runner.py | 438 +++++++++++++++++++++++++++ gateway/app/services/qlib_reader.py | 104 +++++++ gateway/requirements.txt | 3 + gateway/tests/test_agent.py | 26 ++ 12 files changed, 795 insertions(+), 5 deletions(-) create mode 100644 gateway/app/models/agent.py create mode 100644 gateway/app/models/research.py create mode 100644 gateway/app/routers/agent.py create mode 100644 gateway/app/routers/research.py create mode 100644 gateway/app/services/agent_runner.py create mode 100644 gateway/app/services/qlib_reader.py create mode 100644 gateway/tests/test_agent.py diff --git a/.env.example b/.env.example index 51d612fef..a595dcd00 100644 --- a/.env.example +++ b/.env.example @@ -81,4 +81,5 @@ EMBEDDING_MODEL="litellm_proxy/BAAI/bge-large-en-v1.5" # BYBIT_API_KEY= # BYBIT_API_SECRET= # Empty keys are OK for public klines/tickers on testnet. +# UI_TRACE_FOLDER=git_ignore_folder/traces # ========================================== \ No newline at end of file diff --git a/gateway/app/config.py b/gateway/app/config.py index aaa6fdd50..aff72c834 100644 --- a/gateway/app/config.py +++ b/gateway/app/config.py @@ -1,6 +1,12 @@ +from pathlib import Path + from pydantic_settings import BaseSettings, SettingsConfigDict +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", @@ -11,11 +17,19 @@ class Settings(BaseSettings): gateway_host: str = "0.0.0.0" gateway_port: int = 6900 cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"] - app_version: str = "0.1.0" + app_version: str = "0.2.0" bybit_testnet: bool = True bybit_api_key: str = "" bybit_api_secret: str = "" + trace_folder: Path = _repo_root() / "git_ignore_folder" / "traces" + workspace_path: Path = _repo_root() / "git_ignore_folder" / "RD-Agent_workspace" + repo_root: Path = _repo_root() + + @property + def ui_server_port(self) -> int: + return self.gateway_port + settings = Settings() diff --git a/gateway/app/main.py b/gateway/app/main.py index 07f817a97..8d91790af 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -1,12 +1,24 @@ -from fastapi import FastAPI +import sys +from pathlib import Path + +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware import app.brokers.bybit # noqa: F401 — register broker adapters from app.config import settings -from app.routers import health, market +from app.routers import agent, health, market, research +from app.services.agent_runner import agent_runner + + +def _bootstrap_repo_path() -> None: + root = str(settings.repo_root) + if root not in sys.path: + sys.path.insert(0, root) def create_app() -> FastAPI: + _bootstrap_repo_path() + app = FastAPI( title="RD-Agent Terminal Gateway", version=settings.app_version, @@ -24,11 +36,19 @@ def create_app() -> FastAPI: app.include_router(health.router, prefix="/api/v1") app.include_router(market.router, prefix="/api/v1") + app.include_router(agent.router, prefix="/api/v1") + app.include_router(research.router, prefix="/api/v1") @app.get("/") async def root() -> dict[str, str]: return {"service": "rdagent-gateway", "docs": "/docs"} + @app.post("/receive") + async def receive_msgs(request: Request) -> dict[str, str]: + payload = await request.json() + agent_runner.ingest_receive_payload(payload) + return {"status": "success"} + return app diff --git a/gateway/app/models/agent.py b/gateway/app/models/agent.py new file mode 100644 index 000000000..f71016bec --- /dev/null +++ b/gateway/app/models/agent.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field + + +class ScenarioInfo(BaseModel): + name: str + target: str + upload: bool + developer: bool + + +class AgentRunResponse(BaseModel): + id: str + + +class AgentControlRequest(BaseModel): + id: str + action: str = "stop" + + +class UserInteractionRequest(BaseModel): + id: str + payload: dict + + +class TraceMessage(BaseModel): + tag: str + timestamp: str | None = None + content: dict | list | str | None = None + loop_id: int | None = Field(default=None, alias="loop_id") + + model_config = {"populate_by_name": True, "extra": "allow"} diff --git a/gateway/app/models/research.py b/gateway/app/models/research.py new file mode 100644 index 000000000..df2e17cc0 --- /dev/null +++ b/gateway/app/models/research.py @@ -0,0 +1,43 @@ +from typing import Any + +from pydantic import BaseModel + + +class ExperimentSummary(BaseModel): + traceId: str + scenario: str + traceName: str + loopCount: int + messageCount: int + lastTimestamp: str | None = None + + +class LoopMetrics(BaseModel): + loopId: int + metrics: dict[str, Any] + hypothesis: str | None = None + decision: bool | None = None + + +class MetricsResponse(BaseModel): + traceId: str + loops: list[LoopMetrics] + + +class ReturnPoint(BaseModel): + time: str + bench: float + strategy: float + excess: float + + +class ReturnMarker(BaseModel): + time: str + type: str + + +class ReturnsResponse(BaseModel): + traceId: str + loopId: int | None = None + points: list[ReturnPoint] + markers: list[ReturnMarker] diff --git a/gateway/app/routers/__init__.py b/gateway/app/routers/__init__.py index 048d0691b..cee099c04 100644 --- a/gateway/app/routers/__init__.py +++ b/gateway/app/routers/__init__.py @@ -1,4 +1,4 @@ import app.brokers.bybit # noqa: F401 — register BybitAdapter -from app.routers import health, market +from app.routers import agent, health, market, research -__all__ = ["health", "market"] +__all__ = ["agent", "health", "market", "research"] diff --git a/gateway/app/routers/agent.py b/gateway/app/routers/agent.py new file mode 100644 index 000000000..acb6cf24a --- /dev/null +++ b/gateway/app/routers/agent.py @@ -0,0 +1,81 @@ +import asyncio +from typing import Any + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect + +from app.models.agent import AgentControlRequest, AgentRunResponse, UserInteractionRequest +from app.services.agent_runner import agent_runner + +router = APIRouter(prefix="/agent", tags=["agent"]) + + +@router.get("/scenarios") +async def list_scenarios() -> list[dict[str, Any]]: + return agent_runner.list_scenarios() + + +@router.get("/traces") +async def list_traces() -> list[str]: + return agent_runner.list_trace_ids() + + +@router.post("/run", response_model=AgentRunResponse) +async def run_agent( + scenario: str = Form(...), + loops: int | None = Form(default=None), + all_duration: str | None = Form(default=None), + files: list[UploadFile] = File(default=[]), +) -> AgentRunResponse: + try: + trace_id = await agent_runner.start_run(scenario, loops, all_duration, files) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return AgentRunResponse(id=trace_id) + + +@router.get("/trace/{trace_id:path}") +async def get_trace(trace_id: str, offset: int = 0, limit: int = 50, all: bool = False) -> list[dict]: + try: + return agent_runner.get_trace_messages(trace_id, offset=offset, limit=limit, return_all=all) + except Exception as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/control") +async def control_agent(body: AgentControlRequest) -> dict[str, str]: + if body.action != "stop": + raise HTTPException(status_code=400, detail="Only stop action is supported") + try: + agent_runner.stop_trace(body.id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return {"status": "stopped"} + + +@router.post("/user-interaction/submit") +async def submit_user_interaction(body: UserInteractionRequest) -> dict[str, str]: + try: + agent_runner.submit_user_interaction(body.id, body.payload) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"status": "success"} + + +@router.websocket("/ws/trace/{trace_id:path}") +async def trace_websocket(websocket: WebSocket, trace_id: str) -> None: + await websocket.accept() + seen = 0 + try: + while True: + messages = agent_runner.get_trace_messages(trace_id, return_all=True) + if len(messages) > seen: + for msg in messages[seen:]: + await websocket.send_json(msg) + seen = len(messages) + if messages and messages[-1].get("tag") == "END": + break + await asyncio.sleep(1) + except WebSocketDisconnect: + return diff --git a/gateway/app/routers/research.py b/gateway/app/routers/research.py new file mode 100644 index 000000000..0c08d7e8f --- /dev/null +++ b/gateway/app/routers/research.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter, HTTPException + +from app.models.research import ExperimentSummary, MetricsResponse, ReturnsResponse +from app.services import qlib_reader + +router = APIRouter(prefix="/research", tags=["research"]) + + +@router.get("/experiments", response_model=list[ExperimentSummary]) +async def list_experiments() -> list[ExperimentSummary]: + return [ExperimentSummary(**item) for item in qlib_reader.list_experiments()] + + +@router.get("/{trace_id:path}/metrics", response_model=MetricsResponse) +async def get_metrics(trace_id: str) -> MetricsResponse: + try: + payload = qlib_reader.get_metrics(trace_id) + except Exception as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return MetricsResponse(**payload) + + +@router.get("/{trace_id:path}/returns", response_model=ReturnsResponse) +async def get_returns(trace_id: str, loop_id: int | None = None) -> ReturnsResponse: + try: + payload = qlib_reader.get_returns(trace_id, loop_id=loop_id) + except Exception as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return ReturnsResponse(**payload) diff --git a/gateway/app/services/agent_runner.py b/gateway/app/services/agent_runner.py new file mode 100644 index 000000000..d0ff2bc5f --- /dev/null +++ b/gateway/app/services/agent_runner.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +import os +import random +import re +import sys +import traceback +from collections import defaultdict +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime, timezone +from multiprocessing import Process, Queue +from pathlib import Path +from queue import Empty +from typing import Any + +import randomname +from fastapi import UploadFile + +from app.config import settings + +_TARGETS_WITHOUT_USER_INTERACTION = {"general_model", "fin_factor_report"} + + +def _secure_filename(filename: str) -> str: + name = os.path.basename(filename) + return re.sub(r"[^A-Za-z0-9._-]", "_", name) + + +class RDAgentTask: + def __init__( + self, + target_name: str, + kwargs: dict, + stdout_path: str, + log_trace_path: str, + scenario: str, + trace_name: str, + ui_server_port: int | None = None, + create_process: bool = True, + ) -> None: + self.target_name = target_name + self.kwargs = kwargs + self.stdout_path = stdout_path + self.log_trace_path = log_trace_path + self.scenario = scenario + self.trace_name = trace_name + self.ui_server_port = ui_server_port + self.process: Process | None = None + self.user_request_q: Queue = Queue(maxsize=1024) + self.user_response_q: Queue = Queue(maxsize=1024) + + if create_process: + self.process = Process( + target=self._run, + name=f"rdagent:{self.scenario}:{self.trace_name}", + ) + self.messages: list[dict] = [] + self.pointers: defaultdict[str, int] = defaultdict(int) + + def start(self) -> None: + if self.process is not None: + self.process.start() + + def is_alive(self) -> bool: + return self.process is not None and self.process.is_alive() + + def get_end_code(self) -> int: + if self.process is None or self.process.exitcode is None: + return 0 + return self.process.exitcode + + def stop(self) -> None: + if self.process is not None and self.process.is_alive(): + self.process.terminate() + self.process.join() + for q in (self.user_request_q, self.user_response_q): + try: + q.cancel_join_thread() + except Exception: + pass + try: + q.close() + except Exception: + pass + + def _run(self) -> None: + from rdagent.log.conf import LOG_SETTINGS + + LOG_SETTINGS.set_ui_server_port(self.ui_server_port) + + from rdagent.log import rdagent_logger + + rdagent_logger.refresh_storages_from_settings() + rdagent_logger.set_storages_path(self.log_trace_path) + Path(self.stdout_path).parent.mkdir(parents=True, exist_ok=True) + with open(self.stdout_path, "w") as log_file: + with redirect_stdout(log_file), redirect_stderr(log_file): + rdagent_logger.rebind_console_to_current_streams() + try: + if self.target_name not in _TARGETS_WITHOUT_USER_INTERACTION: + self.kwargs.setdefault( + "user_interaction_queues", + (self.user_request_q, self.user_response_q), + ) + if self.target_name == "data_science": + from rdagent.app.data_science.loop import main as data_science + + data_science(**self.kwargs) + elif self.target_name == "general_model": + from rdagent.app.general_model.general_model import ( + extract_models_and_implement as general_model, + ) + + general_model(**self.kwargs) + elif self.target_name == "fin_factor": + from rdagent.app.qlib_rd_loop.factor import main as fin_factor + + fin_factor(**self.kwargs) + elif self.target_name == "fin_factor_report": + from rdagent.app.qlib_rd_loop.factor_from_report import ( + main as fin_factor_report, + ) + + fin_factor_report(**self.kwargs) + elif self.target_name == "fin_model": + from rdagent.app.qlib_rd_loop.model import main as fin_model + + fin_model(**self.kwargs) + elif self.target_name == "fin_quant": + from rdagent.app.qlib_rd_loop.quant import main as fin_quant + + fin_quant(**self.kwargs) + else: + raise ValueError(f"Unknown target: {self.target_name}") + except Exception: + traceback.print_exc() + + +class AgentRunner: + SCENARIOS = [ + { + "name": "Finance Data Building", + "target": "fin_factor", + "upload": False, + "developer": True, + }, + { + "name": "Finance Model Implementation", + "target": "fin_model", + "upload": False, + "developer": True, + }, + { + "name": "Finance Whole Pipeline", + "target": "fin_quant", + "upload": False, + "developer": True, + }, + { + "name": "Finance Data Building (Reports)", + "target": "fin_factor_report", + "upload": True, + "developer": True, + }, + { + "name": "General Model Implementation", + "target": "general_model", + "upload": True, + "developer": False, + }, + ] + + def __init__(self) -> None: + self.trace_root = settings.trace_folder.resolve() + self.processes: dict[str, RDAgentTask] = {} + + def list_scenarios(self) -> list[dict[str, Any]]: + return self.SCENARIOS + + def list_trace_ids(self) -> list[str]: + self._load_existing_traces() + return _collect_existing_trace_ids(self.trace_root) + + def get_task(self, relative_trace_id: str) -> RDAgentTask: + full_id = str(self.trace_root / relative_trace_id) + return self._get_or_create_task(full_id) + + async def start_run( + self, + scenario: str, + loops: int | None, + all_duration: str | None, + files: list[UploadFile], + competition: str | None = None, + ) -> str: + if scenario == "Data Science": + raise ValueError("Data Science scenario is not supported in terminal UI yet") + + if scenario == "Data Science" and competition: + trace_name = f"{competition[10:]}-{randomname.get_name()}" + else: + trace_name = randomname.get_name() + + trace_files_path = self.trace_root / "uploads" / scenario / trace_name + log_trace_path = (self.trace_root / scenario / trace_name).absolute() + stdout_path = self.trace_root / scenario / f"{trace_name}.log" + stdout_path.parent.mkdir(parents=True, exist_ok=True) + + for file in files: + if not file.filename: + continue + target_dir = trace_files_path.resolve() + target_dir.mkdir(parents=True, exist_ok=True) + sanitized = _secure_filename(file.filename) + target_path = (target_dir / sanitized).resolve() + if os.path.commonpath([str(target_path), str(target_dir)]) != str(target_dir): + raise ValueError("Invalid file path") + content = await file.read() + target_path.write_bytes(content) + + target_name, kwargs = self._resolve_target(scenario, trace_files_path, loops, all_duration, files) + task = RDAgentTask( + target_name=target_name, + kwargs=kwargs, + stdout_path=str(stdout_path), + log_trace_path=str(log_trace_path), + scenario=scenario, + trace_name=trace_name, + ui_server_port=settings.ui_server_port, + ) + task.start() + self.processes[str(log_trace_path)] = task + return f"{scenario}/{trace_name}" + + def ingest_receive_payload(self, payload: dict | list) -> None: + items = payload if isinstance(payload, list) else [payload] + for item in items: + trace_id = item.get("id") + msg = item.get("msg") + if not trace_id or not msg: + continue + task = self._get_or_create_task(trace_id) + task.messages.append(msg) + + def get_trace_messages( + self, + relative_trace_id: str, + offset: int = 0, + limit: int = 50, + return_all: bool = False, + ) -> list[dict]: + full_id = str(self.trace_root / relative_trace_id) + task = self._get_or_create_task(full_id) + self._drain_user_requests(task) + self._ensure_end_message(task) + + if return_all or limit <= 0: + return task.messages[offset:] + return task.messages[offset : offset + limit] + + def stop_trace(self, relative_trace_id: str) -> None: + full_id = str(self.trace_root / relative_trace_id) + task = self.processes.get(full_id) + if task is None or task.process is None: + raise KeyError("No running process for given id") + if task.is_alive(): + task.stop() + if not task.messages or task.messages[-1].get("tag") != "END": + task.messages.append( + { + "tag": "END", + "timestamp": datetime.now(timezone.utc).isoformat(), + "content": {"error_msg": "RD-Agent process was stopped by user.", "end_code": -1}, + } + ) + + def submit_user_interaction(self, relative_trace_id: str, payload: dict) -> None: + full_id = str(self.trace_root / relative_trace_id) + task = self._get_or_create_task(full_id) + task.user_response_q.put(payload, block=False) + + def _resolve_target( + self, + scenario: str, + trace_files_path: Path, + loops: int | None, + all_duration: str | None, + files: list[UploadFile], + ) -> tuple[str, dict]: + loop_n_val = loops + all_duration_val = f"{all_duration}h" if all_duration else None + kwargs: dict = {} + + if scenario == "Finance Data Building": + return "fin_factor", { + "loop_n": loop_n_val, + "all_duration": all_duration_val, + "base_features_path": str(trace_files_path), + } + if scenario == "Finance Model Implementation": + return "fin_model", { + "loop_n": loop_n_val, + "all_duration": all_duration_val, + "base_features_path": str(trace_files_path), + } + if scenario == "Finance Whole Pipeline": + return "fin_quant", { + "loop_n": loop_n_val, + "all_duration": all_duration_val, + "base_features_path": str(trace_files_path), + } + if scenario == "Finance Data Building (Reports)": + return "fin_factor_report", {"report_folder": str(trace_files_path), "all_duration": all_duration_val} + if scenario == "General Model Implementation": + if files and files[0].filename: + rfp = str(trace_files_path / _secure_filename(files[0].filename)) + else: + rfp = str(trace_files_path) + return "general_model", {"report_file_path": rfp} + + raise ValueError(f"Unknown scenario: {scenario}") + + def _get_or_create_task(self, trace_id: str) -> RDAgentTask: + task = self.processes.get(trace_id) + if task is None: + task = RDAgentTask( + target_name="", + kwargs={}, + stdout_path="", + log_trace_path=trace_id, + scenario="", + trace_name="", + ui_server_port=None, + create_process=False, + ) + self.processes[trace_id] = task + return task + + def _drain_user_requests(self, task: RDAgentTask) -> None: + try: + req = task.user_request_q.get_nowait() + except Empty: + return + except Exception: + return + + if isinstance(req, dict) and {"tag", "timestamp", "content"}.issubset(req.keys()): + msg = req + else: + msg = { + "tag": "user_interaction.request", + "timestamp": datetime.now(timezone.utc).isoformat(), + "content": req, + } + task.messages.append(msg) + + def _ensure_end_message(self, task: RDAgentTask) -> None: + if task.process is not None and not task.is_alive(): + if not task.messages or task.messages[-1].get("tag") != "END": + task.messages.append( + { + "tag": "END", + "timestamp": datetime.now(timezone.utc).isoformat(), + "content": { + "error_msg": "RD-Agent process has completed.", + "end_code": task.get_end_code(), + }, + } + ) + + def _load_existing_traces(self) -> None: + for trace_id in _collect_existing_trace_ids(self.trace_root): + trace_dir = self.trace_root / trace_id + try: + read_trace_from_disk(trace_dir, str(trace_dir), self.processes) + except Exception: + continue + + +def _collect_existing_trace_ids(trace_root: Path) -> list[str]: + if not trace_root.exists(): + return [] + trace_ids: list[str] = [] + for trace_dir in sorted(trace_root.glob("*/*"), key=lambda p: str(p)): + if not trace_dir.is_dir(): + continue + if "uploads" in trace_dir.relative_to(trace_root).parts: + continue + if not any(trace_dir.rglob("*.pkl")): + continue + trace_ids.append(trace_dir.relative_to(trace_root).as_posix()) + return trace_ids + + +def read_trace_from_disk(log_path: Path, trace_id: str, processes: dict[str, RDAgentTask]) -> None: + from rdagent.log.storage import FileStorage + from rdagent.log.ui.storage import WebStorage + + fs = FileStorage(log_path) + ws = WebStorage(port=1, path=str(log_path)) + task = processes.get(trace_id) + if task is None: + task = RDAgentTask( + target_name="", + kwargs={}, + stdout_path="", + log_trace_path=trace_id, + scenario="", + trace_name="", + ui_server_port=None, + create_process=False, + ) + processes[trace_id] = task + task.messages = [] + last_timestamp = None + for msg in fs.iter_msg(): + data = ws._obj_to_json(obj=msg.content, tag=msg.tag, id=trace_id, timestamp=msg.timestamp.isoformat()) + if data: + if isinstance(data, list): + for item in data: + task.messages.append(item["msg"]) + last_timestamp = msg.timestamp + else: + task.messages.append(data["msg"]) + last_timestamp = msg.timestamp + + now = datetime.now(timezone.utc) + if last_timestamp and (now - last_timestamp).total_seconds() > 1800: + task.messages.append( + { + "tag": "END", + "timestamp": now.isoformat(), + "content": {"error_msg": "Trace session has ended.", "end_code": 0}, + } + ) + + +agent_runner = AgentRunner() diff --git a/gateway/app/services/qlib_reader.py b/gateway/app/services/qlib_reader.py new file mode 100644 index 000000000..e1d54ebcb --- /dev/null +++ b/gateway/app/services/qlib_reader.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pandas as pd + +from app.services.agent_runner import agent_runner, read_trace_from_disk + + +def list_experiments() -> list[dict[str, Any]]: + experiments: list[dict[str, Any]] = [] + for trace_id in agent_runner.list_trace_ids(): + messages = agent_runner.get_trace_messages(trace_id, return_all=True) + parts = trace_id.split("/", 1) + scenario = parts[0] if parts else trace_id + trace_name = parts[1] if len(parts) > 1 else trace_id + loop_ids = {m.get("loop_id") for m in messages if m.get("loop_id") is not None} + last_ts = messages[-1].get("timestamp") if messages else None + experiments.append( + { + "traceId": trace_id, + "scenario": scenario, + "traceName": trace_name, + "loopCount": len(loop_ids), + "messageCount": len(messages), + "lastTimestamp": last_ts, + } + ) + return experiments + + +def get_metrics(trace_id: str) -> dict[str, Any]: + messages = _load_messages(trace_id) + loops: dict[int, dict[str, Any]] = {} + + for msg in messages: + loop_id = msg.get("loop_id") + if loop_id is None: + continue + entry = loops.setdefault(loop_id, {"loopId": loop_id, "metrics": {}, "hypothesis": None, "decision": None}) + + if msg.get("tag") == "feedback.metric": + raw = msg.get("content", {}).get("result") + if raw: + try: + entry["metrics"] = json.loads(raw) + except json.JSONDecodeError: + entry["metrics"] = {"raw": raw} + if msg.get("tag") == "feedback.hypothesis_feedback": + content = msg.get("content", {}) + entry["decision"] = content.get("decision") + entry["hypothesis"] = content.get("new_hypothesis") or content.get("reason") + if msg.get("tag") == "research.hypothesis": + entry["hypothesis"] = msg.get("content", {}).get("hypothesis") + + return {"traceId": trace_id, "loops": sorted(loops.values(), key=lambda x: x["loopId"])} + + +def get_returns(trace_id: str, loop_id: int | None = None) -> dict[str, Any]: + trace_dir = agent_runner.trace_root / trace_id + points: list[dict[str, Any]] = [] + markers: list[dict[str, Any]] = [] + + from rdagent.log.storage import FileStorage + + fs = FileStorage(trace_dir) + for msg in fs.iter_msg(): + if "Quantitative Backtesting Chart" not in msg.tag: + continue + if loop_id is not None and msg.tag and f"Loop_{loop_id}" not in msg.tag and f"loop_{loop_id}" not in msg.tag.lower(): + continue + df = msg.content + if not isinstance(df, pd.DataFrame) or df.empty: + continue + report = _normalize_report_df(df) + for idx, row in report.iterrows(): + points.append( + { + "time": str(idx), + "bench": float(row.get("cum_bench", 0)), + "strategy": float(row.get("cum_return_w_cost", row.get("cum_return_wo_cost", 0))), + "excess": float(row.get("cum_ex_return_w_cost", row.get("cum_ex_return_wo_cost", 0))), + } + ) + if len(report.index) > 1: + markers.append({"time": str(report.index[-1]), "type": "rebalance"}) + break + + return {"traceId": trace_id, "loopId": loop_id, "points": points, "markers": markers} + + +def _load_messages(trace_id: str) -> list[dict]: + full_id = str(agent_runner.trace_root / trace_id) + if full_id not in agent_runner.processes or not agent_runner.processes[full_id].messages: + read_trace_from_disk(agent_runner.trace_root / trace_id, full_id, agent_runner.processes) + return agent_runner.get_trace_messages(trace_id, return_all=True) + + +def _normalize_report_df(df: pd.DataFrame) -> pd.DataFrame: + from rdagent.log.ui.qlib_report_figure import _calculate_report_data + + return _calculate_report_data(df) diff --git a/gateway/requirements.txt b/gateway/requirements.txt index 361a7b19a..b8730ea8f 100644 --- a/gateway/requirements.txt +++ b/gateway/requirements.txt @@ -3,5 +3,8 @@ uvicorn[standard]>=0.32.0 pybit>=5.11.0 pydantic-settings>=2.6.0 httpx>=0.28.0 +pandas>=2.2.0 +randomname>=0.2.1 +python-multipart>=0.0.20 pytest>=8.3.0 pytest-asyncio>=0.24.0 diff --git a/gateway/tests/test_agent.py b/gateway/tests/test_agent.py new file mode 100644 index 000000000..6556112af --- /dev/null +++ b/gateway/tests/test_agent.py @@ -0,0 +1,26 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_agent_scenarios() -> None: + response = client.get("/api/v1/agent/scenarios") + assert response.status_code == 200 + payload = response.json() + assert any(item["name"] == "Finance Data Building" for item in payload) + + +def test_research_experiments() -> None: + response = client.get("/api/v1/research/experiments") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +def test_receive_endpoint() -> None: + response = client.post( + "/receive", + json={"id": "/tmp/trace", "msg": {"tag": "test", "timestamp": "2020-01-01T00:00:00", "content": {}}}, + ) + assert response.status_code == 200 From 4fd6253f31c32448f9ba9e810fb3c8357eb85a4f Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 02:28:17 +0300 Subject: [PATCH 14/16] docs(terminal): update development guide for phase 2 Co-authored-by: Cursor --- docs/terminal/DEVELOPMENT.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/terminal/DEVELOPMENT.md b/docs/terminal/DEVELOPMENT.md index 724ad2c21..ec314aa66 100644 --- a/docs/terminal/DEVELOPMENT.md +++ b/docs/terminal/DEVELOPMENT.md @@ -91,7 +91,12 @@ Ensure gateway `cors_origins` includes `http://localhost:5173`. ### Bybit 502 errors Upstream Bybit API may be rate-limited or unavailable. Retry after a few seconds. -## Related Docs +## Phase 2 — Agent Console + Research Lab + +Requires `pip install -e .` from repo root so gateway can import `rdagent`. + +Agent runs are orchestrated by gateway (`/api/v1/agent/*`) with WebSocket trace streaming. +Research metrics are read from trace pickles via `/api/v1/research/*`. + +Legacy Vue UI (`web/`, `rdagent server_ui`) remains available but terminal is the primary UI for Phase 2. -- [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) -- [PROMPT_SEQUENCE.md](./PROMPT_SEQUENCE.md) From 4b632e002b8f000bca58033622df1f2ceda8a3ff Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 02:28:21 +0300 Subject: [PATCH 15/16] feat(terminal): agent console and research lab UI with phase 2 tabs Co-authored-by: Cursor --- terminal/package-lock.json | 338 +++++++++++++++++- terminal/package.json | 1 + terminal/src/app/router.tsx | 9 +- .../src/components/agent/LoopTimeline.tsx | 32 ++ .../components/research/EquityCurveChart.tsx | 38 ++ .../src/components/research/MetricsTable.tsx | 61 ++++ .../src/components/workspace/StatusBar.tsx | 2 +- terminal/src/hooks/useAgent.ts | 67 ++++ terminal/src/hooks/useResearch.ts | 22 ++ terminal/src/lib/agentApi.ts | 74 ++++ terminal/src/lib/agentTypes.ts | 45 +++ terminal/src/pages/AgentConsole.tsx | 129 +++++++ terminal/src/pages/CommandCenter.tsx | 194 ++++++---- terminal/src/pages/ResearchLab.tsx | 45 +++ terminal/tsconfig.tsbuildinfo | 2 +- 15 files changed, 977 insertions(+), 82 deletions(-) create mode 100644 terminal/src/components/agent/LoopTimeline.tsx create mode 100644 terminal/src/components/research/EquityCurveChart.tsx create mode 100644 terminal/src/components/research/MetricsTable.tsx create mode 100644 terminal/src/hooks/useAgent.ts create mode 100644 terminal/src/hooks/useResearch.ts create mode 100644 terminal/src/lib/agentApi.ts create mode 100644 terminal/src/lib/agentTypes.ts create mode 100644 terminal/src/pages/AgentConsole.tsx create mode 100644 terminal/src/pages/ResearchLab.tsx diff --git a/terminal/package-lock.json b/terminal/package-lock.json index ea711c307..4ea7de4c7 100644 --- a/terminal/package-lock.json +++ b/terminal/package-lock.json @@ -21,6 +21,7 @@ "react-grid-layout": "^1.5.2", "react-resizable": "^3.2.0", "react-router-dom": "^7.9.1", + "recharts": "^2.15.4", "tailwind-merge": "^3.3.1", "zustand": "^5.0.8" }, @@ -269,6 +270,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2194,6 +2204,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2377,9 +2450,129 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2398,6 +2591,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2414,6 +2613,16 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.361", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", @@ -2487,6 +2696,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/fancy-canvas": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", @@ -2558,6 +2773,15 @@ "dev": true, "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2882,6 +3106,12 @@ "fancy-canvas": "2.1.0" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3196,6 +3426,30 @@ "react-dom": ">=18" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-smooth/node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -3218,6 +3472,60 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -3332,6 +3640,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3443,6 +3757,28 @@ } } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", diff --git a/terminal/package.json b/terminal/package.json index 8bd35ef7c..4aadcbbd0 100644 --- a/terminal/package.json +++ b/terminal/package.json @@ -22,6 +22,7 @@ "react-grid-layout": "^1.5.2", "react-resizable": "^3.2.0", "react-router-dom": "^7.9.1", + "recharts": "^2.15.4", "tailwind-merge": "^3.3.1", "zustand": "^5.0.8" }, diff --git a/terminal/src/app/router.tsx b/terminal/src/app/router.tsx index 1bd172412..f65e4e69e 100644 --- a/terminal/src/app/router.tsx +++ b/terminal/src/app/router.tsx @@ -1,9 +1,10 @@ import { createBrowserRouter } from "react-router-dom"; import CommandCenter from "@/pages/CommandCenter"; +import AgentConsole from "@/pages/AgentConsole"; +import ResearchLab from "@/pages/ResearchLab"; export const router = createBrowserRouter([ - { - path: "/", - element: , - }, + { path: "/", element: }, + { path: "/agent", element: }, + { path: "/research", element: }, ]); diff --git a/terminal/src/components/agent/LoopTimeline.tsx b/terminal/src/components/agent/LoopTimeline.tsx new file mode 100644 index 000000000..49bb68954 --- /dev/null +++ b/terminal/src/components/agent/LoopTimeline.tsx @@ -0,0 +1,32 @@ +import { Badge } from "@/components/ui/badge"; +import type { TraceMessage } from "@/lib/agentTypes"; + +interface LoopTimelineProps { + messages: TraceMessage[]; +} + +export function LoopTimeline({ messages }: LoopTimelineProps) { + const items = messages.filter((msg) => + ["research.hypothesis", "feedback.hypothesis_feedback", "feedback.metric", "END"].includes(msg.tag), + ); + + if (!items.length) { + return
Waiting for agent events...
; + } + + return ( +
+ {items.map((msg, index) => ( +
+
+ {msg.tag} + {msg.loop_id !== undefined ? loop {msg.loop_id} : null} +
+
+            {JSON.stringify(msg.content ?? {}, null, 2)}
+          
+
+ ))} +
+ ); +} diff --git a/terminal/src/components/research/EquityCurveChart.tsx b/terminal/src/components/research/EquityCurveChart.tsx new file mode 100644 index 000000000..194ecc96e --- /dev/null +++ b/terminal/src/components/research/EquityCurveChart.tsx @@ -0,0 +1,38 @@ +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { ReturnPoint } from "@/lib/agentTypes"; + +interface EquityCurveChartProps { + points: ReturnPoint[]; +} + +export function EquityCurveChart({ points }: EquityCurveChartProps) { + if (!points.length) { + return
No equity curve data for this trace.
; + } + + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/terminal/src/components/research/MetricsTable.tsx b/terminal/src/components/research/MetricsTable.tsx new file mode 100644 index 000000000..67a7ced81 --- /dev/null +++ b/terminal/src/components/research/MetricsTable.tsx @@ -0,0 +1,61 @@ +import type { LoopMetrics } from "@/lib/agentTypes"; +import { formatPercent, formatPrice } from "@/lib/format"; + +interface MetricsTableProps { + loops: LoopMetrics[]; +} + +const METRIC_KEYS = [ + "IC", + "ICIR", + "Rank IC", + "Rank ICIR", + "1day.excess_return_with_cost.annualized_return", + "1day.excess_return_with_cost.max_drawdown", +]; + +export function MetricsTable({ loops }: MetricsTableProps) { + if (!loops.length) { + return
No qlib metrics found in trace.
; + } + + return ( +
+ + + + + + {METRIC_KEYS.map((key) => ( + + ))} + + + + {loops.map((loop) => ( + + + + {METRIC_KEYS.map((key) => { + const value = loop.metrics[key]; + const display = + typeof value === "number" + ? key.toLowerCase().includes("drawdown") || key.includes("return") + ? formatPercent(value * (key.includes("return") && Math.abs(value) < 2 ? 100 : 1)) + : formatPrice(value, 4) + : value ?? "—"; + return ( + + ); + })} + + ))} + +
LoopDecision + {key} +
{loop.loopId}{loop.decision === true ? "✓" : loop.decision === false ? "✗" : "—"} + {display} +
+
+ ); +} diff --git a/terminal/src/components/workspace/StatusBar.tsx b/terminal/src/components/workspace/StatusBar.tsx index 23971e5ac..b7a9a3c61 100644 --- a/terminal/src/components/workspace/StatusBar.tsx +++ b/terminal/src/components/workspace/StatusBar.tsx @@ -29,7 +29,7 @@ export function StatusBar({ health, healthError, symbol, ticker }: StatusBarProp ) : null}
- RD-Agent Terminal v0.1.0 + RD-Agent Terminal v0.2.0 ); } diff --git a/terminal/src/hooks/useAgent.ts b/terminal/src/hooks/useAgent.ts new file mode 100644 index 000000000..7a7c67bac --- /dev/null +++ b/terminal/src/hooks/useAgent.ts @@ -0,0 +1,67 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + fetchAgentScenarios, + fetchAgentTrace, + fetchAgentTraces, + runAgentScenario, + stopAgentTrace, +} from "@/lib/agentApi"; +import { useEffect, useRef, useState } from "react"; +import type { TraceMessage } from "@/lib/agentTypes"; +import { agentTraceWebSocketUrl } from "@/lib/agentApi"; + +export function useAgentScenarios() { + return useQuery({ queryKey: ["agent-scenarios"], queryFn: fetchAgentScenarios }); +} + +export function useAgentTraces() { + return useQuery({ queryKey: ["agent-traces"], queryFn: fetchAgentTraces, refetchInterval: 30_000 }); +} + +export function useAgentTrace(traceId: string | null) { + return useQuery({ + queryKey: ["agent-trace", traceId], + queryFn: () => fetchAgentTrace(traceId!, 0, 200, true), + enabled: Boolean(traceId), + refetchInterval: traceId ? 5_000 : false, + }); +} + +export function useRunAgent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: runAgentScenario, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["agent-traces"] }); + }, + }); +} + +export function useStopAgent() { + return useMutation({ mutationFn: stopAgentTrace }); +} + +export function useAgentTraceWebSocket(traceId: string | null) { + const [messages, setMessages] = useState([]); + const [connected, setConnected] = useState(false); + const seenRef = useRef(0); + + useEffect(() => { + if (!traceId) return; + seenRef.current = 0; + setMessages([]); + const ws = new WebSocket(agentTraceWebSocketUrl(traceId)); + + ws.onopen = () => setConnected(true); + ws.onclose = () => setConnected(false); + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) as TraceMessage; + setMessages((prev) => [...prev, msg]); + seenRef.current += 1; + }; + + return () => ws.close(); + }, [traceId]); + + return { messages, connected }; +} diff --git a/terminal/src/hooks/useResearch.ts b/terminal/src/hooks/useResearch.ts new file mode 100644 index 000000000..ec07f5681 --- /dev/null +++ b/terminal/src/hooks/useResearch.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchExperiments, fetchResearchMetrics, fetchResearchReturns } from "@/lib/agentApi"; + +export function useExperiments() { + return useQuery({ queryKey: ["research-experiments"], queryFn: fetchExperiments, refetchInterval: 30_000 }); +} + +export function useResearchMetrics(traceId: string | null) { + return useQuery({ + queryKey: ["research-metrics", traceId], + queryFn: () => fetchResearchMetrics(traceId!), + enabled: Boolean(traceId), + }); +} + +export function useResearchReturns(traceId: string | null, loopId?: number) { + return useQuery({ + queryKey: ["research-returns", traceId, loopId], + queryFn: () => fetchResearchReturns(traceId!, loopId), + enabled: Boolean(traceId), + }); +} diff --git a/terminal/src/lib/agentApi.ts b/terminal/src/lib/agentApi.ts new file mode 100644 index 000000000..49b7f109e --- /dev/null +++ b/terminal/src/lib/agentApi.ts @@ -0,0 +1,74 @@ +import type { ExperimentSummary, LoopMetrics, ReturnMarker, ReturnPoint, TraceMessage } from "@/lib/agentTypes"; +import { ApiError } from "@/lib/types"; + +const BASE_URL = import.meta.env.VITE_GATEWAY_URL ?? ""; + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${BASE_URL}${path}`, init); + if (!response.ok) { + let message = `Request failed: ${response.status}`; + try { + const payload = (await response.json()) as Record; + if (typeof payload.detail === "string") message = payload.detail; + } catch { + // ignore + } + throw new ApiError(message, response.status); + } + return (await response.json()) as T; +} + +export function fetchAgentScenarios() { + return request>("/api/v1/agent/scenarios"); +} + +export function fetchAgentTraces() { + return request("/api/v1/agent/traces"); +} + +export function fetchAgentTrace(traceId: string, offset = 0, limit = 100, all = false) { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + all: String(all), + }); + return request(`/api/v1/agent/trace/${traceId}?${params.toString()}`); +} + +export async function runAgentScenario(formData: FormData) { + const response = await fetch(`${BASE_URL}/api/v1/agent/run`, { method: "POST", body: formData }); + if (!response.ok) { + throw new ApiError("Failed to start agent run", response.status); + } + return (await response.json()) as { id: string }; +} + +export function stopAgentTrace(traceId: string) { + return request<{ status: string }>("/api/v1/agent/control", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: traceId, action: "stop" }), + }); +} + +export function fetchExperiments() { + return request("/api/v1/research/experiments"); +} + +export function fetchResearchMetrics(traceId: string) { + return request<{ traceId: string; loops: LoopMetrics[] }>( + `/api/v1/research/${encodeURIComponent(traceId)}/metrics`, + ); +} + +export function fetchResearchReturns(traceId: string, loopId?: number) { + const query = loopId !== undefined ? `?loop_id=${loopId}` : ""; + return request<{ traceId: string; points: ReturnPoint[]; markers: ReturnMarker[] }>( + `/api/v1/research/${encodeURIComponent(traceId)}/returns${query}`, + ); +} + +export function agentTraceWebSocketUrl(traceId: string) { + const base = BASE_URL.replace(/^http/, "ws"); + return `${base}/api/v1/agent/ws/trace/${traceId}`; +} diff --git a/terminal/src/lib/agentTypes.ts b/terminal/src/lib/agentTypes.ts new file mode 100644 index 000000000..6502c57f1 --- /dev/null +++ b/terminal/src/lib/agentTypes.ts @@ -0,0 +1,45 @@ +export const AGENT_SCENARIOS = [ + { name: "Finance Data Building", upload: false, defaultLoops: 3, defaultDuration: 6 }, + { name: "Finance Model Implementation", upload: false, defaultLoops: 3, defaultDuration: 6 }, + { name: "Finance Whole Pipeline", upload: false, defaultLoops: 3, defaultDuration: 6 }, + { name: "Finance Data Building (Reports)", upload: true, defaultLoops: 10, defaultDuration: 24 }, + { name: "General Model Implementation", upload: true, defaultLoops: 1, defaultDuration: 24 }, +] as const; + +export type AgentScenarioName = (typeof AGENT_SCENARIOS)[number]["name"]; + +export interface TraceMessage { + tag: string; + timestamp?: string; + loop_id?: number; + content?: Record | string | unknown[]; + [key: string]: unknown; +} + +export interface ExperimentSummary { + traceId: string; + scenario: string; + traceName: string; + loopCount: number; + messageCount: number; + lastTimestamp?: string | null; +} + +export interface LoopMetrics { + loopId: number; + metrics: Record; + hypothesis?: string | null; + decision?: boolean | null; +} + +export interface ReturnPoint { + time: string; + bench: number; + strategy: number; + excess: number; +} + +export interface ReturnMarker { + time: string; + type: string; +} diff --git a/terminal/src/pages/AgentConsole.tsx b/terminal/src/pages/AgentConsole.tsx new file mode 100644 index 000000000..84f394204 --- /dev/null +++ b/terminal/src/pages/AgentConsole.tsx @@ -0,0 +1,129 @@ +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { LoopTimeline } from "@/components/agent/LoopTimeline"; +import { AGENT_SCENARIOS, type AgentScenarioName } from "@/lib/agentTypes"; +import { + useAgentScenarios, + useAgentTrace, + useAgentTraceWebSocket, + useAgentTraces, + useRunAgent, + useStopAgent, +} from "@/hooks/useAgent"; +import { useState } from "react"; + +export default function AgentConsole() { + const [scenario, setScenario] = useState(AGENT_SCENARIOS[0].name); + const [loops, setLoops] = useState("1"); + const [duration, setDuration] = useState("6"); + const [activeTraceId, setActiveTraceId] = useState(null); + + useAgentScenarios(); + const tracesQuery = useAgentTraces(); + const runMutation = useRunAgent(); + const stopMutation = useStopAgent(); + const traceQuery = useAgentTrace(activeTraceId); + const wsTrace = useAgentTraceWebSocket(activeTraceId); + + const messages = wsTrace.messages.length ? wsTrace.messages : traceQuery.data ?? []; + + const onRun = async () => { + const formData = new FormData(); + formData.append("scenario", scenario); + formData.append("loops", loops); + formData.append("all_duration", duration); + const result = await runMutation.mutateAsync(formData); + setActiveTraceId(result.id); + }; + + return ( +
+
+ + + +
+ + +
+
+ +
+
+
Trace History
+
+ {(tracesQuery.data ?? []).map((traceId) => ( + + ))} +
+
+ +
+
+
Live Trace {activeTraceId ? `• ${activeTraceId}` : ""}
+
+ WS: {wsTrace.connected ? "connected" : "polling/disconnected"} +
+
+ +
+
+
+ ); +} diff --git a/terminal/src/pages/CommandCenter.tsx b/terminal/src/pages/CommandCenter.tsx index d2efb8d22..8cf713294 100644 --- a/terminal/src/pages/CommandCenter.tsx +++ b/terminal/src/pages/CommandCenter.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { CandlestickChart } from "@/components/charts/CandlestickChart"; import { Select, @@ -12,8 +13,19 @@ import { useHealth, useKlines, useSymbols, useTicker } from "@/hooks/useMarket"; import { formatPercent, formatPrice, formatVolume } from "@/lib/format"; import { INTERVAL_OPTIONS } from "@/lib/types"; import { useWorkspaceStore } from "@/stores/workspaceStore"; +import AgentConsole from "@/pages/AgentConsole"; +import ResearchLab from "@/pages/ResearchLab"; + +const TABS = [ + { id: "market", label: "Market" }, + { id: "agent", label: "Agent Console" }, + { id: "research", label: "Research Lab" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; export default function CommandCenter() { + const [tab, setTab] = useState("market"); const { activeSymbol, activeInterval, setActiveSymbol, setActiveInterval } = useWorkspaceStore(); const healthQuery = useHealth(); const symbolsQuery = useSymbols(); @@ -28,85 +40,117 @@ export default function CommandCenter() { return (
-
-
-

RD-Agent Terminal

-

Bybit market view • PR #1

-
-
- - +
+
+
+

RD-Agent Terminal

+

Market • Agent • Research — Phase 2

+
+ {tab === "market" ? ( +
+ + +
+ ) : null}
+
- void klinesQuery.refetch()} +
+ {tab === "market" ? ( + void klinesQuery.refetch()} + /> + } + ticker={ + tickerQuery.isLoading ? ( +
Loading ticker...
+ ) : tickerQuery.data ? ( +
+
+
Last
+
{formatPrice(tickerQuery.data.lastPrice)}
+
+
+
24h Change
+
= 0 ? "text-green-400" : "text-red-400" + }`} + > + {formatPercent(tickerQuery.data.price24hPcnt)} +
+
+
+
24h High
+
{formatPrice(tickerQuery.data.highPrice24h)}
+
+
+
24h Low
+
{formatPrice(tickerQuery.data.lowPrice24h)}
+
+
+
24h Volume
+
{formatVolume(tickerQuery.data.volume24h)}
+
+
+ ) : ( +
Ticker unavailable
+ ) + } /> - } - ticker={ - tickerQuery.isLoading ? ( -
Loading ticker...
- ) : tickerQuery.data ? ( -
-
-
Last
-
{formatPrice(tickerQuery.data.lastPrice)}
-
-
-
24h Change
-
= 0 ? "text-green-400" : "text-red-400" - }`} - > - {formatPercent(tickerQuery.data.price24hPcnt)} -
-
-
-
24h High
-
{formatPrice(tickerQuery.data.highPrice24h)}
-
-
-
24h Low
-
{formatPrice(tickerQuery.data.lowPrice24h)}
-
-
-
24h Volume
-
{formatVolume(tickerQuery.data.volume24h)}
-
-
- ) : ( -
Ticker unavailable
- ) - } - /> + ) : null} + {tab === "agent" ? ( +
+ +
+ ) : null} + {tab === "research" ? ( +
+ +
+ ) : null} +
(null); + const metricsQuery = useResearchMetrics(traceId); + const returnsQuery = useResearchReturns(traceId); + + return ( +
+
+
Experiments
+
+ {(experimentsQuery.data ?? []).map((exp) => ( + + ))} +
+
+ +
+
+
Qlib Metrics
+ +
+
+
Equity Curve
+ +
+
+
+ ); +} diff --git a/terminal/tsconfig.tsbuildinfo b/terminal/tsconfig.tsbuildinfo index e23400c4a..479421223 100644 --- a/terminal/tsconfig.tsbuildinfo +++ b/terminal/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/app/providers.tsx","./src/app/router.tsx","./src/components/charts/candlestickchart.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/workspace/panel.tsx","./src/components/workspace/statusbar.tsx","./src/components/workspace/workspaceshell.tsx","./src/hooks/usemarket.ts","./src/lib/api.ts","./src/lib/format.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/commandcenter.tsx","./src/stores/workspacestore.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/app/providers.tsx","./src/app/router.tsx","./src/components/agent/looptimeline.tsx","./src/components/charts/candlestickchart.tsx","./src/components/research/equitycurvechart.tsx","./src/components/research/metricstable.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/workspace/panel.tsx","./src/components/workspace/statusbar.tsx","./src/components/workspace/workspaceshell.tsx","./src/hooks/useagent.ts","./src/hooks/usemarket.ts","./src/hooks/useresearch.ts","./src/lib/agentapi.ts","./src/lib/agenttypes.ts","./src/lib/api.ts","./src/lib/format.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/agentconsole.tsx","./src/pages/commandcenter.tsx","./src/pages/researchlab.tsx","./src/stores/workspacestore.ts"],"version":"5.9.3"} \ No newline at end of file From 4a14987fa373d5b13d516951bd48183227d8947a Mon Sep 17 00:00:00 2001 From: QWERTY Date: Sun, 24 May 2026 03:07:48 +0300 Subject: [PATCH 16/16] feat(terminal): Phase 2 closure and Phase 3 execution desk Add paper/live order routing with RiskManager, Execution Desk UI, research signal prefill, equity curve markers, Vue deprecation banner, and E2E docs. Co-authored-by: Cursor --- docker-compose.terminal.yml | 4 + docs/terminal/DEVELOPMENT.md | 35 ++++- docs/terminal/E2E_AGENT_RUN.md | 76 +++++++++ docs/terminal/PHASE2_CLOSURE_PLAN.md | 144 +++++++++++++++++ docs/terminal/PHASE3_IMPLEMENTATION_PLAN.md | 140 +++++++++++++++++ gateway/app/brokers/bybit.py | 67 ++++++++ gateway/app/config.py | 7 +- gateway/app/main.py | 3 +- gateway/app/models/execution.py | 59 +++++++ gateway/app/routers/execution.py | 83 ++++++++++ gateway/app/services/execution_service.py | 147 ++++++++++++++++++ gateway/app/services/paper_adapter.py | 99 ++++++++++++ gateway/app/services/qlib_reader.py | 11 +- gateway/app/services/risk_manager.py | 94 +++++++++-- gateway/tests/test_bybit_orders.py | 56 +++++++ gateway/tests/test_execution.py | 93 +++++++++++ gateway/tests/test_risk_manager.py | 52 +++++++ .../src/components/execution/OrderForm.tsx | 103 ++++++++++++ .../components/execution/PositionsTable.tsx | 45 ++++++ .../src/components/execution/RiskBanner.tsx | 60 +++++++ .../components/research/EquityCurveChart.tsx | 30 +++- terminal/src/hooks/useExecution.ts | 81 ++++++++++ terminal/src/lib/executionApi.ts | 67 ++++++++ terminal/src/lib/executionTypes.ts | 64 ++++++++ terminal/src/pages/CommandCenter.tsx | 61 ++++---- terminal/src/pages/ExecutionDesk.tsx | 109 +++++++++++++ terminal/src/pages/ResearchLab.tsx | 33 +++- terminal/src/stores/workspaceStore.ts | 12 ++ web/src/views/Playground.vue | 24 +++ 29 files changed, 1809 insertions(+), 50 deletions(-) create mode 100644 docs/terminal/E2E_AGENT_RUN.md create mode 100644 docs/terminal/PHASE2_CLOSURE_PLAN.md create mode 100644 docs/terminal/PHASE3_IMPLEMENTATION_PLAN.md create mode 100644 gateway/app/models/execution.py create mode 100644 gateway/app/routers/execution.py create mode 100644 gateway/app/services/execution_service.py create mode 100644 gateway/app/services/paper_adapter.py create mode 100644 gateway/tests/test_bybit_orders.py create mode 100644 gateway/tests/test_execution.py create mode 100644 gateway/tests/test_risk_manager.py create mode 100644 terminal/src/components/execution/OrderForm.tsx create mode 100644 terminal/src/components/execution/PositionsTable.tsx create mode 100644 terminal/src/components/execution/RiskBanner.tsx create mode 100644 terminal/src/hooks/useExecution.ts create mode 100644 terminal/src/lib/executionApi.ts create mode 100644 terminal/src/lib/executionTypes.ts create mode 100644 terminal/src/pages/ExecutionDesk.tsx diff --git a/docker-compose.terminal.yml b/docker-compose.terminal.yml index b88f0579d..f88b7920d 100644 --- a/docker-compose.terminal.yml +++ b/docker-compose.terminal.yml @@ -8,6 +8,10 @@ services: - .env environment: BYBIT_TESTNET: "${BYBIT_TESTNET:-true}" + EXECUTION_MODE: "${EXECUTION_MODE:-paper}" + MAX_ORDER_NOTIONAL: "${MAX_ORDER_NOTIONAL:-1000}" + MAX_POSITION_USD: "${MAX_POSITION_USD:-5000}" + DAILY_LOSS_LIMIT: "${DAILY_LOSS_LIMIT:-500}" redis: image: redis:7-alpine diff --git a/docs/terminal/DEVELOPMENT.md b/docs/terminal/DEVELOPMENT.md index ec314aa66..a3473a859 100644 --- a/docs/terminal/DEVELOPMENT.md +++ b/docs/terminal/DEVELOPMENT.md @@ -51,10 +51,26 @@ GATEWAY_PORT=6900 BYBIT_TESTNET=true BYBIT_API_KEY= BYBIT_API_SECRET= + +# Phase 3 — Execution (paper by default) +EXECUTION_MODE=paper +MAX_ORDER_NOTIONAL=1000 +MAX_POSITION_USD=5000 +DAILY_LOSS_LIMIT=500 ``` Public klines/tickers work **without** API keys on Bybit testnet. +### Bybit testnet keys (live execution) + +For `EXECUTION_MODE=live` on testnet: + +1. Create API key at [Bybit testnet](https://testnet.bybit.com/) with **Contract Trade** permissions +2. Set `BYBIT_API_KEY` and `BYBIT_API_SECRET` in `.env` +3. Keep `BYBIT_TESTNET=true` — mainnet is blocked in Phase 3 gateway + +Paper mode (`EXECUTION_MODE=paper`, default) simulates fills at mid price with no keys required. + ## Docker (gateway only) ```powershell @@ -98,5 +114,22 @@ Requires `pip install -e .` from repo root so gateway can import `rdagent`. Agent runs are orchestrated by gateway (`/api/v1/agent/*`) with WebSocket trace streaming. Research metrics are read from trace pickles via `/api/v1/research/*`. -Legacy Vue UI (`web/`, `rdagent server_ui`) remains available but terminal is the primary UI for Phase 2. +Legacy Vue UI (`web/`, `rdagent server_ui`) remains available but terminal is the primary UI for Phase 2+. + +## Phase 3 — Execution Desk + +Paper trading by default (`EXECUTION_MODE=paper`). Orders flow: + +1. Terminal **Execution Desk** → `POST /api/v1/execution/orders` +2. Gateway **RiskManager** validates notional, position, daily loss, kill switch +3. **PaperAdapter** (default) or **BybitAdapter** (live testnet) executes + +Endpoints: + +- `GET /api/v1/execution/status` — mode and risk limits +- `GET /api/v1/execution/positions` — open positions +- `GET /api/v1/execution/pnl` — P&L snapshot +- `WS /api/v1/execution/ws/pnl` — live P&L stream + +Research Lab **Use as signal** prefills Execution Desk (manual confirm only). diff --git a/docs/terminal/E2E_AGENT_RUN.md b/docs/terminal/E2E_AGENT_RUN.md new file mode 100644 index 000000000..d2c151e0d --- /dev/null +++ b/docs/terminal/E2E_AGENT_RUN.md @@ -0,0 +1,76 @@ +# E2E Agent Run — Finance Data Building (1 loop) + +Manual end-to-end validation for Phase 2 agent integration. + +## Prerequisites + +| Requirement | Check | +|-------------|-------| +| Python 3.10+ | `python --version` | +| RD-Agent installed | `pip install -e .` from repo root | +| LLM keys in `.env` | `OPENAI_API_KEY`, `CHAT_MODEL` | +| Qlib data (cn) | `~/.qlib/qlib_data/cn_data` or Docker | +| Qlib env | `MODEL_CoSTEER_env_type=docker` (WSL2/Linux) | +| Gateway deps | `pip install -r gateway/requirements.txt` | + +## Start services + +```powershell +# Terminal 1 — Gateway +cd gateway +uvicorn app.main:app --host 0.0.0.0 --port 6900 --reload + +# Terminal 2 — React UI +cd terminal +npm run dev +``` + +Open http://localhost:5173 → **Agent Console** tab. + +## Run test + +1. Scenario: **Finance Data Building** +2. Loops: **1** +3. Click **Run Agent** +4. Observe trace ID in history panel +5. WebSocket / poll should show tags in order: + - `research.hypothesis` + - `research.experiment` + - `feedback.metric` + - `END` (or loop completion) + +## Verify Research Lab + +1. Switch to **Research Lab** tab +2. Select the new trace in Experiments list +3. **Qlib Metrics** table shows loop metrics (IC, annualized return, etc.) +4. **Equity Curve** shows bench/strategy/excess lines +5. **Amber dots** = rebalance markers from `ret.pkl` + +## API smoke (optional) + +```powershell +# List experiments +curl http://localhost:6900/api/v1/research/experiments + +# Returns + markers (replace TRACE_ID) +curl http://localhost:6900/api/v1/research/experiments/TRACE_ID/returns +``` + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `ModuleNotFoundError: rdagent` | `pip install -e .` from repo root | +| Agent subprocess exits immediately | Check `.env` LLM keys; gateway logs | +| No experiments in Research Lab | Wait for trace to finish; check `trace_folder` in gateway config | +| Empty equity curve | Trace may lack `Quantitative Backtesting Chart` pickle | +| Qlib docker errors | WSL2 + Docker; see main README qlib section | + +## Success criteria + +- [ ] Agent run completes 1 loop without crash +- [ ] Trace visible in Agent Console history +- [ ] Research Lab shows metrics for trace +- [ ] Equity curve renders with markers +- [ ] Gateway tests pass: `pytest gateway/tests -q` diff --git a/docs/terminal/PHASE2_CLOSURE_PLAN.md b/docs/terminal/PHASE2_CLOSURE_PLAN.md new file mode 100644 index 000000000..800abe46b --- /dev/null +++ b/docs/terminal/PHASE2_CLOSURE_PLAN.md @@ -0,0 +1,144 @@ +# Phase 2 — Закрытие DoD: анализ, промпты, план + +> **Ветка:** `feat/terminal-phase2-agent-research` +> **Базовый план:** [PHASE2_IMPLEMENTATION_PLAN.md](./PHASE2_IMPLEMENTATION_PLAN.md) + +--- + +## 1. Анализ оставшихся пунктов DoD + +| # | Item | Текущий статус | Блокер | Решение | Приоритет | +|---|------|----------------|--------|---------|-----------| +| 1 | **Live fin_factor E2E** | Agent API готов, UI готов | LLM keys + qlib Docker/WSL2 | Чеклист + smoke script; ручной E2E 1 loop | P0 | +| 2 | **Signal overlay на chart** | API `markers[]` есть | Qlib dates ≠ Bybit unix time | Markers на **EquityCurveChart** (Recharts); Market chart — без overlay | P1 | +| 3 | **Vue deprecation redirect** | Только docs | — | Banner в `web/` + README link | P1 | +| 4 | **Chart markers из returns** | 1 marker (last rebalance) | Partial parser | Расширить qlib_reader: rebalance + drawdown markers | P1 | +| 5 | **PR Phase 2** | Код на ветке | — | `gh pr create` | P0 | +| 6 | **Phase 3** | Не начат | — | Отдельный plan + prompts | P2 | + +### 1.1 Live fin_factor E2E — детали + +**Что уже работает:** +- `POST /api/v1/agent/run` → subprocess `fin_factor` +- `POST /receive` ← WebStorage +- `WS /api/v1/agent/ws/trace/{id}` +- AgentConsole UI: scenario, loops, stop + +**Что нужно для E2E:** +```env +OPENAI_API_KEY=... +CHAT_MODEL=gpt-4o +MODEL_CoSTEER_env_type=docker # WSL2/Linux +# ~/.qlib/qlib_data/cn_data +pip install -e . # из корня RD-Agent +``` + +**Критерий успеха E2E:** +1. Run `Finance Data Building`, loops=1 +2. Trace ID появляется в Agent Console +3. WS/poll получает tags: `research.hypothesis`, `feedback.metric`, `END` +4. Research Lab показывает metrics + equity curve + +### 1.2 Signal overlay — архитектурное ограничение + +| Chart | Data source | Marker time format | Overlay feasible? | +|-------|-------------|-------------------|-------------------| +| Bybit CandlestickChart | pybit klines | Unix seconds | ❌ для qlib CSI300 | +| EquityCurveChart | ret.pkl via qlib_reader | Date strings (T0, 2020-…) | ✅ | + +**Решение:** Phase 2 DoD для overlay = markers на **Research equity curve**, не на crypto market chart. Phase 3+ может добавить unified symbol mapping. + +### 1.3 Vue deprecation + +Минимальный professional approach: +- Banner в Vue Playground: «New Terminal UI → http://localhost:5173» +- README: terminal = primary UI для quant scenarios +- Flask `server_ui` остаётся для backward compat + +--- + +## 2. Prompt Sequence — закрытие Phase 2 (P2-C) + +### P2-C1 — Equity curve markers (Recharts) + +``` +Добавь markers на EquityCurveChart из returnsQuery.markers. +ReferenceDot или Scatter на time/strategy. +ResearchLab передаёт markers в chart. +Gate: при наличии trace с ret.pkl видны точки rebalance. +Коммит: feat(terminal): equity curve markers from research returns +``` + +### P2-C2 — Расширить qlib_reader markers + +``` +В gateway/app/services/qlib_reader.py: +- markers для каждого 5-го rebalance point в report index +- marker type: rebalance | period_end +- unit test с mock DataFrame +Коммит: feat(gateway): enrich returns markers from qlib report +``` + +### P2-C3 — Vue deprecation banner + +``` +web/src/views/Playground.vue — banner вверху: +"RD-Agent Terminal (React) is the primary UI → http://localhost:5173" +Не ломать existing flow. +Коммит: chore(web): deprecation banner pointing to React terminal +``` + +### P2-C4 — E2E checklist + +``` +Создай docs/terminal/E2E_AGENT_RUN.md: +- prerequisites (.env, docker, qlib data) +- step-by-step Finance Data Building 1 loop +- expected tags timeline +- troubleshooting +Коммит: docs(terminal): E2E agent run checklist +``` + +### P2-C5 — PR Phase 2 + +``` +gh pr create из feat/terminal-phase2-agent-research → main +Title: feat(terminal): Phase 2 agent console and research lab +Body: summary, test plan, breaking changes none +``` + +--- + +## 3. Phase 3 — Preview (Execution + Risk) + +| Epic | Deliverable | +|------|-------------| +| 3.1 | Order ticket UI (Bybit testnet) | +| 3.2 | RiskManager enforcement | +| 3.3 | PaperAdapter | +| 3.4 | Manual approval gate signal→order | +| 3.5 | Live P&L WebSocket | + +**Branch:** `feat/terminal-phase3-execution` +**Prompt doc:** `PROMPT_SEQUENCE_PHASE3.md` (создать при старте Phase 3) + +--- + +## 4. Definition of Done — финальный чеклист Phase 2 + +- [ ] P2-C1 Equity markers UI +- [ ] P2-C2 Gateway markers enriched +- [ ] P2-C3 Vue banner +- [ ] P2-C4 E2E doc +- [ ] Manual E2E fin_factor 1 loop (user + keys) +- [ ] P2-C5 PR merged or open +- [ ] Gateway tests 11/11 +- [ ] Terminal build OK + +--- + +## 5. Рекомендуемый порядок выполнения + +``` +P2-C2 → P2-C1 → P2-C3 → P2-C4 → (manual E2E) → P2-C5 → Phase 3 planning +``` diff --git a/docs/terminal/PHASE3_IMPLEMENTATION_PLAN.md b/docs/terminal/PHASE3_IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..2eb06cb78 --- /dev/null +++ b/docs/terminal/PHASE3_IMPLEMENTATION_PLAN.md @@ -0,0 +1,140 @@ +# Phase 3 — Execution + Risk (Bybit) + +> **Prerequisite:** Phase 2 merged (agent + research lab) +> **Branch:** `feat/terminal-phase3-execution` +> **Scope:** Bybit testnet first; Tiger Trade in Phase 4 + +--- + +## 1. Goals + +| Goal | Description | +|------|-------------| +| Order placement | Market/limit orders via Bybit testnet | +| Risk gates | Max position, daily loss, order size limits | +| Paper trading | Simulated fills before live testnet | +| Manual approval | Research signal → user confirms → order | +| Live P&L | Positions + unrealized P&L in terminal | + +--- + +## 2. Architecture + +``` +terminal/OrderTicket.tsx + ↓ POST /api/v1/execution/orders +gateway/routers/execution.py + ↓ +RiskManager.check(order) → PaperAdapter | BybitAdapter.place_order() + ↓ +WebSocket /api/v1/execution/ws/pnl +``` + +--- + +## 3. Gateway deliverables + +| File | Purpose | +|------|---------| +| `gateway/app/services/risk_manager.py` | Rules engine: max_qty, max_notional, daily_loss | +| `gateway/app/services/paper_adapter.py` | In-memory fills, slippage model | +| `gateway/app/brokers/bybit.py` | Extend: `place_order`, `cancel_order`, `get_positions` | +| `gateway/app/routers/execution.py` | REST orders + WS P&L | +| `gateway/app/models/execution.py` | OrderRequest, OrderResponse, Position | + +--- + +## 4. Terminal deliverables + +| File | Purpose | +|------|---------| +| `terminal/src/pages/ExecutionDesk.tsx` | Order ticket, positions, P&L | +| `terminal/src/hooks/useExecution.ts` | TanStack Query + WS | +| `CommandCenter.tsx` | Tab: Execution Desk | +| `terminal/src/components/execution/OrderForm.tsx` | Symbol, side, qty, type | +| `terminal/src/components/execution/RiskBanner.tsx` | Blocked reasons from RiskManager | + +--- + +## 5. Prompt Sequence Phase 3 + +### P3-1 — RiskManager + models + +``` +Create gateway/app/services/risk_manager.py and models/execution.py. +Rules: MAX_ORDER_NOTIONAL, MAX_POSITION_USD, DAILY_LOSS_LIMIT from env. +Unit tests with pytest. +``` + +### P3-2 — Bybit order methods + +``` +Extend BybitAdapter: place_order, cancel_order, get_positions. +Testnet only; read API keys from config. +Mock tests in gateway/tests/test_bybit_orders.py. +``` + +### P3-3 — PaperAdapter + +``` +PaperAdapter implements same interface as live broker. +Simulated fill at mid price; store in memory dict. +``` + +### P3-4 — Execution router + +``` +POST /api/v1/execution/orders — validate → risk → paper|live. +GET /api/v1/execution/positions +WS /api/v1/execution/ws/pnl — push on fill/ticker. +``` + +### P3-5 — Order ticket UI + +``` +ExecutionDesk page: symbol from market store, side, qty, submit. +Show risk rejection message inline. +TanStack mutation for POST orders. +``` + +### P3-6 — Manual signal → order (MVP) + +``` +ResearchLab: "Use as signal" button → prefills ExecutionDesk symbol + side hint. +No auto-trade; user must confirm. +``` + +### P3-7 — Docker + docs + +``` +docker-compose.terminal.yml: execution env vars. +DEVELOPMENT.md: Bybit testnet API key setup. +``` + +### P3-8 — QA + +``` +pytest gateway/tests +npm run build +Manual: paper order BTCUSDT testnet +``` + +--- + +## 6. Phase 3 DoD + +- [x] Paper order flow E2E on testnet symbol +- [x] RiskManager blocks oversize orders +- [x] Positions visible in UI +- [x] P&L updates via WebSocket +- [x] No changes to `rdagent/` core +- [x] Tests green; terminal build OK + +--- + +## 7. Out of scope (Phase 4) + +- Tiger Trade adapter +- Auto-trading from agent signals without approval +- Multi-account / portfolio optimization +- Production mainnet keys diff --git a/gateway/app/brokers/bybit.py b/gateway/app/brokers/bybit.py index 596cd141b..b36b31289 100644 --- a/gateway/app/brokers/bybit.py +++ b/gateway/app/brokers/bybit.py @@ -6,6 +6,7 @@ from app.brokers.base import register_broker from app.brokers.errors import BrokerNotFoundError, BrokerUpstreamError from app.config import settings +from app.models.execution import OrderRequest, OrderResponse, OrderSide, OrderType, Position from app.models.market import OHLCVBar, Symbol, Ticker @@ -109,3 +110,69 @@ def _ensure_success(self, response: dict[str, Any], symbol: str | None = None) - if ret_code in {10001, 10002, 10003, 10004, 10005, 10006, 10007, 10017}: raise BrokerNotFoundError(ret_msg or f"Symbol not found: {symbol}") raise BrokerUpstreamError(ret_msg) + + async def place_order(self, order: OrderRequest, category: str | None = None) -> OrderResponse: + cat = category or order.category + payload: dict[str, Any] = { + "category": cat, + "symbol": order.symbol, + "side": order.side.value, + "orderType": order.order_type.value, + "qty": str(order.qty), + } + if order.order_type == OrderType.LIMIT and order.price is not None: + payload["price"] = str(order.price) + + response = await asyncio.to_thread(self._client.place_order, **payload) + self._ensure_success(response, symbol=order.symbol) + result = response.get("result", {}) + order_id = result.get("orderId", "") + return OrderResponse( + order_id=order_id, + symbol=order.symbol, + side=order.side, + order_type=order.order_type, + qty=order.qty, + price=order.price, + fill_price=order.price, + status="Submitted", + mode="live", + ) + + async def cancel_order(self, symbol: str, order_id: str, category: str = "linear") -> dict[str, str]: + response = await asyncio.to_thread( + self._client.cancel_order, + category=category, + symbol=symbol, + orderId=order_id, + ) + self._ensure_success(response, symbol=symbol) + return {"status": "cancelled", "order_id": order_id} + + async def get_positions(self, category: str = "linear", symbol: str | None = None) -> list[Position]: + params: dict[str, Any] = {"category": category} + if symbol: + params["symbol"] = symbol + response = await asyncio.to_thread(self._client.get_positions, **params) + self._ensure_success(response, symbol=symbol) + rows = response.get("result", {}).get("list", []) + positions: list[Position] = [] + for row in rows: + size = float(row.get("size", 0) or 0) + if size <= 0: + continue + avg_price = float(row.get("avgPrice", 0) or 0) + mark_price = float(row.get("markPrice", avg_price) or avg_price) + upnl = float(row.get("unrealisedPnl", 0) or 0) + positions.append( + Position( + symbol=row.get("symbol", symbol or ""), + side=row.get("side", "Buy"), + size=size, + avg_price=avg_price, + mark_price=mark_price, + unrealized_pnl=upnl, + notional_usd=size * mark_price, + ) + ) + return positions diff --git a/gateway/app/config.py b/gateway/app/config.py index aff72c834..0bc2dc3d4 100644 --- a/gateway/app/config.py +++ b/gateway/app/config.py @@ -17,12 +17,17 @@ class Settings(BaseSettings): gateway_host: str = "0.0.0.0" gateway_port: int = 6900 cors_origins: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"] - app_version: str = "0.2.0" + app_version: str = "0.3.0" bybit_testnet: bool = True bybit_api_key: str = "" bybit_api_secret: str = "" + execution_mode: str = "paper" + max_order_notional: float = 1000.0 + max_position_usd: float = 5000.0 + daily_loss_limit: float = 500.0 + trace_folder: Path = _repo_root() / "git_ignore_folder" / "traces" workspace_path: Path = _repo_root() / "git_ignore_folder" / "RD-Agent_workspace" repo_root: Path = _repo_root() diff --git a/gateway/app/main.py b/gateway/app/main.py index 8d91790af..17bb39181 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -6,7 +6,7 @@ import app.brokers.bybit # noqa: F401 — register broker adapters from app.config import settings -from app.routers import agent, health, market, research +from app.routers import agent, execution, health, market, research from app.services.agent_runner import agent_runner @@ -38,6 +38,7 @@ def create_app() -> FastAPI: app.include_router(market.router, prefix="/api/v1") app.include_router(agent.router, prefix="/api/v1") app.include_router(research.router, prefix="/api/v1") + app.include_router(execution.router, prefix="/api/v1") @app.get("/") async def root() -> dict[str, str]: diff --git a/gateway/app/models/execution.py b/gateway/app/models/execution.py new file mode 100644 index 000000000..dbace67c5 --- /dev/null +++ b/gateway/app/models/execution.py @@ -0,0 +1,59 @@ +from enum import Enum + +from pydantic import BaseModel, Field + + +class OrderSide(str, Enum): + BUY = "Buy" + SELL = "Sell" + + +class OrderType(str, Enum): + MARKET = "Market" + LIMIT = "Limit" + + +class OrderRequest(BaseModel): + symbol: str = Field(min_length=1) + side: OrderSide + order_type: OrderType = OrderType.MARKET + qty: float = Field(gt=0) + price: float | None = Field(default=None, gt=0) + category: str = "linear" + broker: str = "bybit" + + +class OrderResponse(BaseModel): + order_id: str + symbol: str + side: OrderSide + order_type: OrderType + qty: float + price: float | None + fill_price: float | None = None + status: str + mode: str + + +class Position(BaseModel): + symbol: str + side: str + size: float + avg_price: float + mark_price: float + unrealized_pnl: float + notional_usd: float + + +class RiskCheckResult(BaseModel): + allowed: bool + reasons: list[str] = Field(default_factory=list) + + +class PnLSnapshot(BaseModel): + mode: str + total_unrealized_pnl: float + total_realized_pnl: float + daily_pnl: float + kill_switch_active: bool + positions: list[Position] diff --git a/gateway/app/routers/execution.py b/gateway/app/routers/execution.py new file mode 100644 index 000000000..9db0e0532 --- /dev/null +++ b/gateway/app/routers/execution.py @@ -0,0 +1,83 @@ +import asyncio + +from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect + +from app.models.execution import OrderRequest, OrderResponse, PnLSnapshot, Position +from app.services.execution_service import execution_service + +router = APIRouter(prefix="/execution", tags=["execution"]) + + +@router.get("/status") +async def execution_status() -> dict: + return await execution_service.get_status() + + +@router.post("/orders", response_model=OrderResponse) +async def place_order(body: OrderRequest) -> OrderResponse: + try: + return await execution_service.submit_order(body) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.delete("/orders/{order_id}") +async def cancel_order(order_id: str, symbol: str, category: str = "linear") -> dict[str, str]: + try: + return await execution_service.cancel_order(symbol=symbol, order_id=order_id, category=category) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.get("/positions", response_model=list[Position]) +async def list_positions(category: str = "linear") -> list[Position]: + try: + return await execution_service.get_positions(category=category) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.get("/pnl", response_model=PnLSnapshot) +async def get_pnl(category: str = "linear") -> PnLSnapshot: + try: + return await execution_service.get_pnl_snapshot(category=category) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.post("/kill-switch/activate") +async def activate_kill_switch(reason: str = "Manual kill switch") -> dict[str, str]: + execution_service.risk.activate_kill_switch(reason) + await execution_service._broadcast_pnl() + return {"status": "active", "reason": reason} + + +@router.post("/kill-switch/deactivate") +async def deactivate_kill_switch() -> dict[str, str]: + execution_service.risk.deactivate_kill_switch() + await execution_service._broadcast_pnl() + return {"status": "inactive"} + + +@router.websocket("/ws/pnl") +async def pnl_websocket(websocket: WebSocket, category: str = "linear") -> None: + await websocket.accept() + queue = execution_service.subscribe_pnl() + try: + snapshot = await execution_service.get_pnl_snapshot(category=category) + await websocket.send_json(snapshot.model_dump()) + while True: + try: + update = await asyncio.wait_for(queue.get(), timeout=2.0) + await websocket.send_json(update.model_dump()) + except asyncio.TimeoutError: + snapshot = await execution_service.get_pnl_snapshot(category=category) + await websocket.send_json(snapshot.model_dump()) + except WebSocketDisconnect: + pass + finally: + execution_service.unsubscribe_pnl(queue) diff --git a/gateway/app/services/execution_service.py b/gateway/app/services/execution_service.py new file mode 100644 index 000000000..c27c8da02 --- /dev/null +++ b/gateway/app/services/execution_service.py @@ -0,0 +1,147 @@ +"""Order routing: risk checks then paper or live Bybit execution.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from fastapi import HTTPException + +from app.brokers.base import get_broker +from app.brokers.bybit import BybitAdapter +from app.config import settings +from app.models.execution import OrderRequest, OrderResponse, PnLSnapshot, Position +from app.services.paper_adapter import PaperAdapter +from app.services.risk_manager import RiskManager + + +class ExecutionService: + def __init__(self) -> None: + self.risk = RiskManager() + self.paper = PaperAdapter() + self._pnl_queues: set[asyncio.Queue[PnLSnapshot]] = set() + + @property + def mode(self) -> str: + return settings.execution_mode + + async def _mark_prices(self, symbols: list[str], category: str = "linear") -> dict[str, float]: + broker = get_broker("bybit") + prices: dict[str, float] = {} + for symbol in symbols: + ticker = await broker.get_ticker(symbol=symbol, category=category) + prices[symbol] = ticker.lastPrice + return prices + + async def _current_positions(self, category: str = "linear") -> list[Position]: + if self.mode == "paper": + symbols = list(self.paper._positions.keys()) + if not symbols: + return [] + marks = await self._mark_prices(symbols, category=category) + return await self.paper.get_positions(marks) + + broker = get_broker("bybit") + if not isinstance(broker, BybitAdapter): + return [] + return await broker.get_positions(category=category) + + async def submit_order(self, order: OrderRequest) -> OrderResponse: + if settings.bybit_testnet is False and self.mode == "live": + raise HTTPException(status_code=403, detail="Live mainnet orders are disabled in Phase 3") + + broker = get_broker(order.broker) + ticker = await broker.get_ticker(symbol=order.symbol, category=order.category) + mark_price = ticker.lastPrice + positions = await self._current_positions(category=order.category) + + risk = self.risk.check_order(order, mark_price=mark_price, positions=positions) + if not risk.allowed: + raise HTTPException(status_code=422, detail={"reasons": risk.reasons}) + + if self.mode == "paper": + response = await self.paper.place_order(order, mark_price=mark_price) + else: + if not isinstance(broker, BybitAdapter): + raise HTTPException(status_code=400, detail="Live execution supports Bybit only") + response = await broker.place_order(order) + if response.fill_price: + realized = 0.0 + self.risk.record_realized_pnl(realized) + + await self._broadcast_pnl(category=order.category) + return response + + async def cancel_order(self, symbol: str, order_id: str, category: str = "linear") -> dict[str, str]: + if self.mode == "paper": + if order_id not in self.paper._orders: + raise HTTPException(status_code=404, detail="Order not found") + return {"status": "cancelled", "order_id": order_id} + + broker = get_broker("bybit") + if not isinstance(broker, BybitAdapter): + raise HTTPException(status_code=400, detail="Cancel supports Bybit only") + await broker.cancel_order(symbol=symbol, order_id=order_id, category=category) + return {"status": "cancelled", "order_id": order_id} + + async def get_positions(self, category: str = "linear") -> list[Position]: + return await self._current_positions(category=category) + + async def get_pnl_snapshot(self, category: str = "linear") -> PnLSnapshot: + positions = await self._current_positions(category=category) + unrealized = sum(p.unrealized_pnl for p in positions) + if self.mode == "paper": + realized = self.paper.total_realized_pnl() + else: + realized = 0.0 + + return PnLSnapshot( + mode=self.mode, + total_unrealized_pnl=unrealized, + total_realized_pnl=realized, + daily_pnl=self.risk.daily_pnl, + kill_switch_active=self.risk.is_kill_switch_active(), + positions=positions, + ) + + def subscribe_pnl(self) -> asyncio.Queue[PnLSnapshot]: + queue: asyncio.Queue[PnLSnapshot] = asyncio.Queue(maxsize=8) + self._pnl_queues.add(queue) + return queue + + def unsubscribe_pnl(self, queue: asyncio.Queue[PnLSnapshot]) -> None: + self._pnl_queues.discard(queue) + + async def _broadcast_pnl(self, category: str = "linear") -> None: + snapshot = await self.get_pnl_snapshot(category=category) + dead: list[asyncio.Queue[PnLSnapshot]] = [] + for queue in self._pnl_queues: + try: + queue.put_nowait(snapshot) + except asyncio.QueueFull: + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + queue.put_nowait(snapshot) + except asyncio.QueueFull: + dead.append(queue) + for queue in dead: + self.unsubscribe_pnl(queue) + + async def get_status(self) -> dict[str, Any]: + return { + "mode": self.mode, + "kill_switch_active": self.risk.is_kill_switch_active(), + "kill_switch_reason": self.risk.kill_switch_reason(), + "daily_pnl": self.risk.daily_pnl, + "limits": { + "max_order_notional": self.risk.max_order_notional, + "max_position_usd": self.risk.max_position_usd, + "daily_loss_limit": self.risk.daily_loss_limit, + }, + } + + +execution_service = ExecutionService() diff --git a/gateway/app/services/paper_adapter.py b/gateway/app/services/paper_adapter.py new file mode 100644 index 000000000..ad8f9088f --- /dev/null +++ b/gateway/app/services/paper_adapter.py @@ -0,0 +1,99 @@ +"""In-memory paper trading adapter.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +from app.models.execution import OrderRequest, OrderResponse, OrderSide, OrderType, Position + + +@dataclass +class _PaperPosition: + size: float = 0.0 + avg_price: float = 0.0 + realized_pnl: float = 0.0 + + +@dataclass +class PaperAdapter: + slippage_bps: float = 5.0 + _positions: dict[str, _PaperPosition] = field(default_factory=dict) + _orders: dict[str, OrderResponse] = field(default_factory=dict) + + def _apply_slippage(self, side: OrderSide, price: float) -> float: + slip = price * (self.slippage_bps / 10_000) + return price + slip if side == OrderSide.BUY else price - slip + + async def place_order(self, order: OrderRequest, mark_price: float) -> OrderResponse: + fill_price = order.price if order.order_type == OrderType.LIMIT and order.price else mark_price + fill_price = self._apply_slippage(order.side, fill_price) + order_id = f"paper-{uuid.uuid4().hex[:12]}" + + pos = self._positions.setdefault(order.symbol, _PaperPosition()) + signed_qty = order.qty if order.side == OrderSide.BUY else -order.qty + new_size = pos.size + signed_qty + + if pos.size == 0: + pos.size = new_size + pos.avg_price = fill_price + elif (pos.size > 0 and signed_qty > 0) or (pos.size < 0 and signed_qty < 0): + total_cost = abs(pos.size) * pos.avg_price + abs(signed_qty) * fill_price + pos.size = new_size + pos.avg_price = total_cost / abs(new_size) + else: + closed = min(abs(pos.size), abs(signed_qty)) + if pos.size > 0: + pos.realized_pnl += closed * (fill_price - pos.avg_price) + else: + pos.realized_pnl += closed * (pos.avg_price - fill_price) + + if abs(signed_qty) <= abs(pos.size): + pos.size = new_size + if abs(pos.size) < 1e-12: + pos.avg_price = 0.0 + else: + pos.size = new_size + pos.avg_price = fill_price + + response = OrderResponse( + order_id=order_id, + symbol=order.symbol, + side=order.side, + order_type=order.order_type, + qty=order.qty, + price=order.price, + fill_price=fill_price, + status="Filled", + mode="paper", + ) + self._orders[order_id] = response + return response + + async def get_positions(self, mark_prices: dict[str, float]) -> list[Position]: + positions: list[Position] = [] + for symbol, pos in self._positions.items(): + if abs(pos.size) < 1e-12: + continue + mark = mark_prices.get(symbol, pos.avg_price) + side = "Buy" if pos.size > 0 else "Sell" + size = abs(pos.size) + if pos.size > 0: + upnl = size * (mark - pos.avg_price) + else: + upnl = size * (pos.avg_price - mark) + positions.append( + Position( + symbol=symbol, + side=side, + size=size, + avg_price=pos.avg_price, + mark_price=mark, + unrealized_pnl=upnl, + notional_usd=size * mark, + ) + ) + return positions + + def total_realized_pnl(self) -> float: + return sum(p.realized_pnl for p in self._positions.values()) diff --git a/gateway/app/services/qlib_reader.py b/gateway/app/services/qlib_reader.py index e1d54ebcb..cfab05d3a 100644 --- a/gateway/app/services/qlib_reader.py +++ b/gateway/app/services/qlib_reader.py @@ -84,8 +84,15 @@ def get_returns(trace_id: str, loop_id: int | None = None) -> dict[str, Any]: "excess": float(row.get("cum_ex_return_w_cost", row.get("cum_ex_return_wo_cost", 0))), } ) - if len(report.index) > 1: - markers.append({"time": str(report.index[-1]), "type": "rebalance"}) + # Rebalance markers: first, periodic, and last period in backtest report + index_list = list(report.index) + marker_indices = {0, len(index_list) - 1} + if len(index_list) > 5: + step = max(1, len(index_list) // 5) + marker_indices.update(range(0, len(index_list), step)) + for i in sorted(marker_indices): + if i < len(index_list): + markers.append({"time": str(index_list[i]), "type": "rebalance"}) break return {"traceId": trace_id, "loopId": loop_id, "points": points, "markers": markers} diff --git a/gateway/app/services/risk_manager.py b/gateway/app/services/risk_manager.py index 3b7540e6a..2f4d0656e 100644 --- a/gateway/app/services/risk_manager.py +++ b/gateway/app/services/risk_manager.py @@ -1,26 +1,90 @@ -"""Risk management service (Phase 3). +"""Risk management for Phase 3 execution.""" -Planned controls: -- kill switch: halt all new orders immediately -- max notional per symbol -- daily loss limit with auto kill-switch -- manual approval gate before live order execution -""" +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import date +from app.config import settings +from app.models.execution import OrderRequest, Position, RiskCheckResult + + +@dataclass class RiskManager: - """Stub for Phase 3 execution risk controls.""" + max_order_notional: float = field(default_factory=lambda: settings.max_order_notional) + max_position_usd: float = field(default_factory=lambda: settings.max_position_usd) + daily_loss_limit: float = field(default_factory=lambda: settings.daily_loss_limit) + _kill_switch_active: bool = False + _kill_switch_reason: str = "" + _daily_pnl: float = 0.0 + _daily_pnl_date: date = field(default_factory=date.today) + + def _roll_daily_pnl(self) -> None: + today = date.today() + if today != self._daily_pnl_date: + self._daily_pnl = 0.0 + self._daily_pnl_date = today + + def record_realized_pnl(self, amount: float) -> None: + self._roll_daily_pnl() + self._daily_pnl += amount + if self._daily_pnl <= -self.daily_loss_limit: + self.activate_kill_switch(f"Daily loss limit reached ({self._daily_pnl:.2f})") - def check_order(self, *args, **kwargs) -> bool: - """Validate order against risk limits. Not enforced in PR #1.""" - raise NotImplementedError("RiskManager is a Phase 3 stub") + @property + def daily_pnl(self) -> float: + self._roll_daily_pnl() + return self._daily_pnl def is_kill_switch_active(self) -> bool: - """Return True if kill switch is engaged.""" - return False + return self._kill_switch_active def activate_kill_switch(self, reason: str) -> None: - """Engage kill switch and block new orders.""" + self._kill_switch_active = True + self._kill_switch_reason = reason def deactivate_kill_switch(self) -> None: - """Disengage kill switch after manual review.""" + self._kill_switch_active = False + self._kill_switch_reason = "" + + def kill_switch_reason(self) -> str: + return self._kill_switch_reason + + def check_order( + self, + order: OrderRequest, + mark_price: float, + positions: list[Position], + ) -> RiskCheckResult: + reasons: list[str] = [] + + if self.is_kill_switch_active(): + reasons.append(self._kill_switch_reason or "Kill switch is active") + + if order.order_type.value == "Limit" and order.price is None: + reasons.append("Limit orders require price") + + ref_price = order.price if order.order_type.value == "Limit" and order.price else mark_price + if ref_price <= 0: + reasons.append("Invalid reference price for risk check") + + notional = order.qty * ref_price + if notional > self.max_order_notional: + reasons.append( + f"Order notional ${notional:.2f} exceeds max ${self.max_order_notional:.2f}" + ) + + position = next((p for p in positions if p.symbol == order.symbol), None) + current_notional = position.notional_usd if position else 0.0 + delta = notional if order.side.value == "Buy" else -notional + projected = abs(current_notional + delta) + if projected > self.max_position_usd: + reasons.append( + f"Projected position ${projected:.2f} exceeds max ${self.max_position_usd:.2f}" + ) + + self._roll_daily_pnl() + if self._daily_pnl <= -self.daily_loss_limit: + reasons.append(f"Daily loss limit reached ({self._daily_pnl:.2f})") + + return RiskCheckResult(allowed=len(reasons) == 0, reasons=reasons) diff --git a/gateway/tests/test_bybit_orders.py b/gateway/tests/test_bybit_orders.py new file mode 100644 index 000000000..8ca7fa555 --- /dev/null +++ b/gateway/tests/test_bybit_orders.py @@ -0,0 +1,56 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from app.brokers.bybit import BybitAdapter +from app.models.execution import OrderRequest, OrderSide, OrderType + + +@pytest.fixture +def adapter() -> BybitAdapter: + with patch("app.brokers.bybit.HTTP"): + return BybitAdapter(testnet=True, api_key="key", api_secret="secret") + + +@pytest.mark.asyncio +async def test_place_order_market(adapter: BybitAdapter) -> None: + adapter._client.place_order = MagicMock( + return_value={"retCode": 0, "result": {"orderId": "abc123"}} + ) + order = OrderRequest(symbol="BTCUSDT", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=0.01) + response = await adapter.place_order(order) + assert response.order_id == "abc123" + assert response.mode == "live" + adapter._client.place_order.assert_called_once() + + +@pytest.mark.asyncio +async def test_cancel_order(adapter: BybitAdapter) -> None: + adapter._client.cancel_order = MagicMock(return_value={"retCode": 0, "result": {}}) + result = await adapter.cancel_order("BTCUSDT", "abc123") + assert result["status"] == "cancelled" + + +@pytest.mark.asyncio +async def test_get_positions(adapter: BybitAdapter) -> None: + adapter._client.get_positions = MagicMock( + return_value={ + "retCode": 0, + "result": { + "list": [ + { + "symbol": "BTCUSDT", + "side": "Buy", + "size": "0.01", + "avgPrice": "65000", + "markPrice": "66000", + "unrealisedPnl": "10", + } + ] + }, + } + ) + positions = await adapter.get_positions() + assert len(positions) == 1 + assert positions[0].symbol == "BTCUSDT" + assert positions[0].size == 0.01 diff --git a/gateway/tests/test_execution.py b/gateway/tests/test_execution.py new file mode 100644 index 000000000..21e02657c --- /dev/null +++ b/gateway/tests/test_execution.py @@ -0,0 +1,93 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.models.execution import OrderSide, OrderType +from app.services.execution_service import execution_service + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_execution_state() -> None: + execution_service.paper._positions.clear() + execution_service.paper._orders.clear() + execution_service.risk.deactivate_kill_switch() + yield + execution_service.paper._positions.clear() + execution_service.paper._orders.clear() + execution_service.risk.deactivate_kill_switch() + + +def test_execution_status(client: TestClient) -> None: + response = client.get("/api/v1/execution/status") + assert response.status_code == 200 + payload = response.json() + assert payload["mode"] == "paper" + assert "limits" in payload + + +@patch("app.services.execution_service.get_broker") +def test_paper_order_success(mock_get_broker, client: TestClient) -> None: + mock_broker = AsyncMock() + mock_broker.get_ticker.return_value = type("T", (), {"lastPrice": 65000.0})() + mock_get_broker.return_value = mock_broker + + response = client.post( + "/api/v1/execution/orders", + json={ + "symbol": "BTCUSDT", + "side": OrderSide.BUY.value, + "order_type": OrderType.MARKET.value, + "qty": 0.001, + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["mode"] == "paper" + assert payload["status"] == "Filled" + + +@patch("app.services.execution_service.get_broker") +def test_paper_order_risk_rejection(mock_get_broker, client: TestClient) -> None: + mock_broker = AsyncMock() + mock_broker.get_ticker.return_value = type("T", (), {"lastPrice": 65000.0})() + mock_get_broker.return_value = mock_broker + execution_service.risk.max_order_notional = 10 + + response = client.post( + "/api/v1/execution/orders", + json={ + "symbol": "BTCUSDT", + "side": OrderSide.BUY.value, + "order_type": OrderType.MARKET.value, + "qty": 1, + }, + ) + assert response.status_code == 422 + execution_service.risk.max_order_notional = 1000 + + +@patch("app.services.execution_service.get_broker") +def test_get_positions_after_order(mock_get_broker, client: TestClient) -> None: + mock_broker = AsyncMock() + mock_broker.get_ticker.return_value = type("T", (), {"lastPrice": 65000.0})() + mock_get_broker.return_value = mock_broker + + client.post( + "/api/v1/execution/orders", + json={ + "symbol": "BTCUSDT", + "side": OrderSide.BUY.value, + "order_type": OrderType.MARKET.value, + "qty": 0.001, + }, + ) + response = client.get("/api/v1/execution/positions") + assert response.status_code == 200 + assert len(response.json()) == 1 diff --git a/gateway/tests/test_risk_manager.py b/gateway/tests/test_risk_manager.py new file mode 100644 index 000000000..3f317bb32 --- /dev/null +++ b/gateway/tests/test_risk_manager.py @@ -0,0 +1,52 @@ +import pytest + +from app.models.execution import OrderRequest, OrderSide, OrderType, Position +from app.services.risk_manager import RiskManager + + +def _order(qty: float = 0.01, side: OrderSide = OrderSide.BUY) -> OrderRequest: + return OrderRequest(symbol="BTCUSDT", side=side, order_type=OrderType.MARKET, qty=qty) + + +def test_check_order_allows_small_order() -> None: + rm = RiskManager(max_order_notional=1000, max_position_usd=5000, daily_loss_limit=500) + result = rm.check_order(_order(qty=0.001), mark_price=65000, positions=[]) + assert result.allowed is True + + +def test_check_order_blocks_oversize_notional() -> None: + rm = RiskManager(max_order_notional=100, max_position_usd=5000, daily_loss_limit=500) + result = rm.check_order(_order(qty=1), mark_price=65000, positions=[]) + assert result.allowed is False + assert any("notional" in r.lower() for r in result.reasons) + + +def test_check_order_blocks_position_limit() -> None: + rm = RiskManager(max_order_notional=10000, max_position_usd=1000, daily_loss_limit=500) + positions = [ + Position( + symbol="BTCUSDT", + side="Buy", + size=0.01, + avg_price=65000, + mark_price=65000, + unrealized_pnl=0, + notional_usd=650, + ) + ] + result = rm.check_order(_order(qty=0.01), mark_price=65000, positions=positions) + assert result.allowed is False + + +def test_kill_switch_blocks_orders() -> None: + rm = RiskManager() + rm.activate_kill_switch("test") + result = rm.check_order(_order(), mark_price=65000, positions=[]) + assert result.allowed is False + assert result.reasons[0] == "test" + + +def test_daily_loss_triggers_kill_switch() -> None: + rm = RiskManager(daily_loss_limit=100) + rm.record_realized_pnl(-150) + assert rm.is_kill_switch_active() is True diff --git a/terminal/src/components/execution/OrderForm.tsx b/terminal/src/components/execution/OrderForm.tsx new file mode 100644 index 000000000..ffc24904c --- /dev/null +++ b/terminal/src/components/execution/OrderForm.tsx @@ -0,0 +1,103 @@ +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ExecutionPrefill, OrderSide, OrderType } from "@/lib/executionTypes"; +import { useEffect, useState } from "react"; + +interface OrderFormProps { + symbol: string; + prefill?: ExecutionPrefill | null; + onSubmit: (payload: { symbol: string; side: OrderSide; order_type: OrderType; qty: number; price?: number }) => void; + submitting?: boolean; +} + +export function OrderForm({ symbol, prefill, onSubmit, submitting }: OrderFormProps) { + const [side, setSide] = useState(prefill?.side ?? "Buy"); + const [orderType, setOrderType] = useState("Market"); + const [qty, setQty] = useState("0.001"); + const [price, setPrice] = useState(""); + + useEffect(() => { + if (prefill?.side) setSide(prefill.side); + }, [prefill?.side, prefill?.sourceTraceId]); + + return ( +
+ {prefill?.note ? ( +
+ {prefill.note} +
+ ) : null} +
+
+ +
+ {symbol} +
+
+
+ + +
+
+ + +
+
+ + setQty(e.target.value)} + /> +
+ {orderType === "Limit" ? ( +
+ + setPrice(e.target.value)} + /> +
+ ) : null} +
+ +
+ ); +} diff --git a/terminal/src/components/execution/PositionsTable.tsx b/terminal/src/components/execution/PositionsTable.tsx new file mode 100644 index 000000000..fad9dc8fe --- /dev/null +++ b/terminal/src/components/execution/PositionsTable.tsx @@ -0,0 +1,45 @@ +import type { Position } from "@/lib/executionTypes"; +import { formatPrice } from "@/lib/format"; + +interface PositionsTableProps { + positions: Position[]; +} + +export function PositionsTable({ positions }: PositionsTableProps) { + if (!positions.length) { + return
No open positions.
; + } + + return ( +
+ + + + + + + + + + + + + {positions.map((p) => ( + + + + + + + + + ))} + +
SymbolSideSizeAvgMarkuPnL
{p.symbol}{p.side}{p.size}{formatPrice(p.avg_price)}{formatPrice(p.mark_price)}= 0 ? "text-green-400" : "text-red-400"}`} + > + {formatPrice(p.unrealized_pnl)} +
+
+ ); +} diff --git a/terminal/src/components/execution/RiskBanner.tsx b/terminal/src/components/execution/RiskBanner.tsx new file mode 100644 index 000000000..52605970a --- /dev/null +++ b/terminal/src/components/execution/RiskBanner.tsx @@ -0,0 +1,60 @@ +import { Button } from "@/components/ui/button"; +import type { ExecutionStatus } from "@/lib/executionTypes"; + +interface RiskBannerProps { + status?: ExecutionStatus; + rejection?: string | null; +} + +export function RiskBanner({ status, rejection }: RiskBannerProps) { + if (rejection) { + return ( +
+ Order blocked: {rejection} +
+ ); + } + + if (status?.kill_switch_active) { + return ( +
+ Kill switch active — new orders blocked + {status.kill_switch_reason ? `: ${status.kill_switch_reason}` : ""} +
+ ); + } + + if (!status) return null; + + return ( +
+ Mode: {status.mode} · Max order ${status.limits.max_order_notional} · Max + position ${status.limits.max_position_usd} · Daily loss limit ${status.limits.daily_loss_limit} +
+ ); +} + +interface KillSwitchControlsProps { + active: boolean; + onActivate: () => void; + onDeactivate: () => void; + loading?: boolean; +} + +export function KillSwitchControls({ active, onActivate, onDeactivate, loading }: KillSwitchControlsProps) { + return active ? ( + + ) : ( + + ); +} diff --git a/terminal/src/components/research/EquityCurveChart.tsx b/terminal/src/components/research/EquityCurveChart.tsx index 194ecc96e..2f45fbea2 100644 --- a/terminal/src/components/research/EquityCurveChart.tsx +++ b/terminal/src/components/research/EquityCurveChart.tsx @@ -1,24 +1,29 @@ +import type { ReturnMarker, ReturnPoint } from "@/lib/agentTypes"; import { CartesianGrid, Legend, Line, LineChart, + ReferenceDot, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; -import type { ReturnPoint } from "@/lib/agentTypes"; interface EquityCurveChartProps { points: ReturnPoint[]; + markers?: ReturnMarker[]; } -export function EquityCurveChart({ points }: EquityCurveChartProps) { +export function EquityCurveChart({ points, markers = [] }: EquityCurveChartProps) { if (!points.length) { return
No equity curve data for this trace.
; } + const markerTimes = new Set(markers.map((m) => m.time)); + const markerColor = (type: string) => (type === "rebalance" ? "#f59e0b" : "#22c55e"); + return (
@@ -31,8 +36,29 @@ export function EquityCurveChart({ points }: EquityCurveChartProps) { + {points + .filter((p) => markerTimes.has(p.time)) + .map((p) => { + const marker = markers.find((m) => m.time === p.time); + return ( + + ); + })} + {markers.length ? ( +
+ Markers: {markers.length} rebalance points (amber dots on strategy line) +
+ ) : null}
); } diff --git a/terminal/src/hooks/useExecution.ts b/terminal/src/hooks/useExecution.ts new file mode 100644 index 000000000..e3fb29a4b --- /dev/null +++ b/terminal/src/hooks/useExecution.ts @@ -0,0 +1,81 @@ +import { + activateKillSwitch, + deactivateKillSwitch, + fetchExecutionStatus, + fetchPnL, + fetchPositions, + pnlWebSocketUrl, + submitOrder, +} from "@/lib/executionApi"; +import type { OrderRequest, PnLSnapshot } from "@/lib/executionTypes"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +export function useExecutionStatus() { + return useQuery({ + queryKey: ["execution", "status"], + queryFn: fetchExecutionStatus, + refetchInterval: 10_000, + }); +} + +export function usePositions() { + return useQuery({ + queryKey: ["execution", "positions"], + queryFn: () => fetchPositions(), + refetchInterval: 5_000, + }); +} + +export function usePnL() { + return useQuery({ + queryKey: ["execution", "pnl"], + queryFn: () => fetchPnL(), + refetchInterval: 5_000, + }); +} + +export function useSubmitOrder() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: OrderRequest) => submitOrder(body), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["execution"] }); + }, + }); +} + +export function useKillSwitch() { + const queryClient = useQueryClient(); + const activate = useMutation({ + mutationFn: (reason?: string) => activateKillSwitch(reason), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["execution"] }), + }); + const deactivate = useMutation({ + mutationFn: () => deactivateKillSwitch(), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["execution"] }), + }); + return { activate, deactivate }; +} + +export function usePnLWebSocket(enabled = true) { + const [snapshot, setSnapshot] = useState(null); + const [connected, setConnected] = useState(false); + + useEffect(() => { + if (!enabled) return; + const ws = new WebSocket(pnlWebSocketUrl()); + ws.onopen = () => setConnected(true); + ws.onclose = () => setConnected(false); + ws.onmessage = (event) => { + try { + setSnapshot(JSON.parse(event.data as string) as PnLSnapshot); + } catch { + // ignore malformed payloads + } + }; + return () => ws.close(); + }, [enabled]); + + return { snapshot, connected }; +} diff --git a/terminal/src/lib/executionApi.ts b/terminal/src/lib/executionApi.ts new file mode 100644 index 000000000..4ea2c6bcb --- /dev/null +++ b/terminal/src/lib/executionApi.ts @@ -0,0 +1,67 @@ +import { ApiError } from "@/lib/types"; +import type { + ExecutionStatus, + OrderRequest, + OrderResponse, + PnLSnapshot, + Position, +} from "@/lib/executionTypes"; + +const BASE_URL = import.meta.env.VITE_GATEWAY_URL ?? ""; + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${BASE_URL}${path}`, init); + if (!response.ok) { + let message = `Request failed: ${response.status}`; + try { + const payload = (await response.json()) as Record; + if (typeof payload.detail === "string") message = payload.detail; + else if (payload.detail && typeof payload.detail === "object") { + const detail = payload.detail as { reasons?: string[] }; + if (detail.reasons?.length) message = detail.reasons.join("; "); + } + } catch { + // ignore parse errors + } + throw new ApiError(message, response.status); + } + return (await response.json()) as T; +} + +export function fetchExecutionStatus(): Promise { + return request("/api/v1/execution/status"); +} + +export function fetchPositions(category = "linear"): Promise { + const params = new URLSearchParams({ category }); + return request(`/api/v1/execution/positions?${params.toString()}`); +} + +export function fetchPnL(category = "linear"): Promise { + const params = new URLSearchParams({ category }); + return request(`/api/v1/execution/pnl?${params.toString()}`); +} + +export function submitOrder(body: OrderRequest): Promise { + return request("/api/v1/execution/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export function activateKillSwitch(reason = "Manual kill switch"): Promise<{ status: string }> { + const params = new URLSearchParams({ reason }); + return request(`/api/v1/execution/kill-switch/activate?${params.toString()}`, { method: "POST" }); +} + +export function deactivateKillSwitch(): Promise<{ status: string }> { + return request("/api/v1/execution/kill-switch/deactivate", { method: "POST" }); +} + +export function pnlWebSocketUrl(category = "linear"): string { + const base = BASE_URL || window.location.origin; + const wsBase = base.replace(/^http/, "ws"); + const params = new URLSearchParams({ category }); + return `${wsBase}/api/v1/execution/ws/pnl?${params.toString()}`; +} diff --git a/terminal/src/lib/executionTypes.ts b/terminal/src/lib/executionTypes.ts new file mode 100644 index 000000000..def0ca53e --- /dev/null +++ b/terminal/src/lib/executionTypes.ts @@ -0,0 +1,64 @@ +export type OrderSide = "Buy" | "Sell"; +export type OrderType = "Market" | "Limit"; + +export interface OrderRequest { + symbol: string; + side: OrderSide; + order_type: OrderType; + qty: number; + price?: number; + category?: string; + broker?: string; +} + +export interface OrderResponse { + order_id: string; + symbol: string; + side: OrderSide; + order_type: OrderType; + qty: number; + price: number | null; + fill_price: number | null; + status: string; + mode: string; +} + +export interface Position { + symbol: string; + side: string; + size: number; + avg_price: number; + mark_price: number; + unrealized_pnl: number; + notional_usd: number; +} + +export interface PnLSnapshot { + mode: string; + total_unrealized_pnl: number; + total_realized_pnl: number; + daily_pnl: number; + kill_switch_active: boolean; + positions: Position[]; +} + +export interface ExecutionStatus { + mode: string; + kill_switch_active: boolean; + kill_switch_reason: string; + daily_pnl: number; + limits: { + max_order_notional: number; + max_position_usd: number; + daily_loss_limit: number; + }; +} + +export interface ExecutionPrefill { + symbol: string; + side: OrderSide; + sourceTraceId?: string; + note?: string; +} + +export type CommandCenterTab = "market" | "agent" | "research" | "execution"; diff --git a/terminal/src/pages/CommandCenter.tsx b/terminal/src/pages/CommandCenter.tsx index 8cf713294..0b6b45629 100644 --- a/terminal/src/pages/CommandCenter.tsx +++ b/terminal/src/pages/CommandCenter.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { CandlestickChart } from "@/components/charts/CandlestickChart"; import { Select, @@ -11,22 +10,23 @@ import { StatusBar } from "@/components/workspace/StatusBar"; import { WorkspaceShell } from "@/components/workspace/WorkspaceShell"; import { useHealth, useKlines, useSymbols, useTicker } from "@/hooks/useMarket"; import { formatPercent, formatPrice, formatVolume } from "@/lib/format"; +import type { CommandCenterTab } from "@/lib/executionTypes"; import { INTERVAL_OPTIONS } from "@/lib/types"; import { useWorkspaceStore } from "@/stores/workspaceStore"; import AgentConsole from "@/pages/AgentConsole"; +import ExecutionDesk from "@/pages/ExecutionDesk"; import ResearchLab from "@/pages/ResearchLab"; -const TABS = [ +const TABS: { id: CommandCenterTab; label: string }[] = [ { id: "market", label: "Market" }, { id: "agent", label: "Agent Console" }, { id: "research", label: "Research Lab" }, -] as const; - -type TabId = (typeof TABS)[number]["id"]; + { id: "execution", label: "Execution Desk" }, +]; export default function CommandCenter() { - const [tab, setTab] = useState("market"); - const { activeSymbol, activeInterval, setActiveSymbol, setActiveInterval } = useWorkspaceStore(); + const { activeSymbol, activeInterval, activeTab, setActiveSymbol, setActiveInterval, setActiveTab } = + useWorkspaceStore(); const healthQuery = useHealth(); const symbolsQuery = useSymbols(); const klinesQuery = useKlines(activeSymbol, activeInterval); @@ -44,9 +44,9 @@ export default function CommandCenter() {

RD-Agent Terminal

-

Market • Agent • Research — Phase 2

+

Market • Agent • Research • Execution — Phase 3

- {tab === "market" ? ( + {activeTab === "market" || activeTab === "execution" ? (
- + {activeTab === "market" ? ( + + ) : null}
) : null}
@@ -80,9 +82,11 @@ export default function CommandCenter() {
- {tab === "market" ? ( + {activeTab === "market" ? ( ) : null} - {tab === "agent" ? ( + {activeTab === "agent" ? (
) : null} - {tab === "research" ? ( + {activeTab === "research" ? (
) : null} + {activeTab === "execution" ? ( +
+ +
+ ) : null}
(null); + const [lastOrderId, setLastOrderId] = useState(null); + + const pnl = wsSnapshot ?? pnlQuery.data; + + return ( +
+
+
+
Order Ticket
+ +
+ { + setLastError(null); + submitOrder.mutate(payload, { + onSuccess: (data) => { + setLastOrderId(data.order_id); + clearExecutionPrefill(); + }, + onError: (err) => setLastError(err instanceof Error ? err.message : "Order failed"), + }); + }} + /> +
+ {lastOrderId ? ( +
Last order: {lastOrderId}
+ ) : null} +
+ +
+
+
Risk Controls
+ killSwitch.activate.mutate("Manual kill switch from terminal")} + onDeactivate={() => killSwitch.deactivate.mutate()} + /> +
+
+
+ +
+
+
+
P&L
+
{connected ? "WS live" : "Polling"}
+
+ {pnl ? ( +
+
+
Unrealized
+
= 0 ? "text-green-400" : "text-red-400"}`}> + {formatPrice(pnl.total_unrealized_pnl)} +
+
+
+
Realized
+
{formatPrice(pnl.total_realized_pnl)}
+
+
+
Daily
+
= 0 ? "text-green-400" : "text-red-400"}`}> + {formatPrice(pnl.daily_pnl)} +
+
+
+
Mode
+
{pnl.mode}
+
+
+ ) : ( +
Loading P&L...
+ )} +
+ +
+
Positions
+ +
+
+
+ ); +} diff --git a/terminal/src/pages/ResearchLab.tsx b/terminal/src/pages/ResearchLab.tsx index 890674e43..dba82f4c2 100644 --- a/terminal/src/pages/ResearchLab.tsx +++ b/terminal/src/pages/ResearchLab.tsx @@ -1,13 +1,35 @@ +import { Button } from "@/components/ui/button"; import { EquityCurveChart } from "@/components/research/EquityCurveChart"; import { MetricsTable } from "@/components/research/MetricsTable"; import { useExperiments, useResearchMetrics, useResearchReturns } from "@/hooks/useResearch"; +import { useWorkspaceStore } from "@/stores/workspaceStore"; import { useState } from "react"; +function inferSideFromReturns(points: { excess: number }[]): "Buy" | "Sell" { + if (points.length < 2) return "Buy"; + const last = points[points.length - 1]?.excess ?? 0; + const prev = points[points.length - 2]?.excess ?? 0; + return last >= prev ? "Buy" : "Sell"; +} + export default function ResearchLab() { const experimentsQuery = useExperiments(); const [traceId, setTraceId] = useState(null); const metricsQuery = useResearchMetrics(traceId); const returnsQuery = useResearchReturns(traceId); + const { activeSymbol, setActiveTab, setExecutionPrefill } = useWorkspaceStore(); + + const handleUseAsSignal = () => { + const points = returnsQuery.data?.points ?? []; + const side = inferSideFromReturns(points); + setExecutionPrefill({ + symbol: activeSymbol, + side, + sourceTraceId: traceId ?? undefined, + note: `Research signal from trace ${traceId ?? "unknown"} — ${side} hint on ${activeSymbol}. Confirm manually in Execution Desk.`, + }); + setActiveTab("execution"); + }; return (
@@ -32,12 +54,19 @@ export default function ResearchLab() {
-
Qlib Metrics
+
+
Qlib Metrics
+ {traceId ? ( + + ) : null} +
Equity Curve
- +
diff --git a/terminal/src/stores/workspaceStore.ts b/terminal/src/stores/workspaceStore.ts index e5cdb271e..e5b9c6024 100644 --- a/terminal/src/stores/workspaceStore.ts +++ b/terminal/src/stores/workspaceStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { Layout } from "react-grid-layout"; +import type { CommandCenterTab, ExecutionPrefill } from "@/lib/executionTypes"; const DEFAULT_LAYOUT: Layout[] = [ { i: "chart", x: 0, y: 0, w: 8, h: 12, minW: 4, minH: 8 }, @@ -13,9 +14,14 @@ interface WorkspaceState { layout: Layout[]; activeSymbol: string; activeInterval: string; + activeTab: CommandCenterTab; + executionPrefill: ExecutionPrefill | null; setLayout: (layout: Layout[]) => void; setActiveSymbol: (symbol: string) => void; setActiveInterval: (interval: string) => void; + setActiveTab: (tab: CommandCenterTab) => void; + setExecutionPrefill: (prefill: ExecutionPrefill | null) => void; + clearExecutionPrefill: () => void; } export const useWorkspaceStore = create()( @@ -24,9 +30,14 @@ export const useWorkspaceStore = create()( layout: DEFAULT_LAYOUT, activeSymbol: "BTCUSDT", activeInterval: "60", + activeTab: "market", + executionPrefill: null, setLayout: (layout) => set({ layout }), setActiveSymbol: (activeSymbol) => set({ activeSymbol }), setActiveInterval: (activeInterval) => set({ activeInterval }), + setActiveTab: (activeTab) => set({ activeTab }), + setExecutionPrefill: (executionPrefill) => set({ executionPrefill }), + clearExecutionPrefill: () => set({ executionPrefill: null }), }), { name: "rdagent-terminal-workspace", @@ -34,6 +45,7 @@ export const useWorkspaceStore = create()( layout: state.layout, activeSymbol: state.activeSymbol, activeInterval: state.activeInterval, + activeTab: state.activeTab, }), }, ), diff --git a/web/src/views/Playground.vue b/web/src/views/Playground.vue index a2ce6f72e..a84c5b19d 100644 --- a/web/src/views/Playground.vue +++ b/web/src/views/Playground.vue @@ -1,5 +1,9 @@