feat(calibration): complete step 1 data inspection with data quality v1
This commit is contained in:
0
src/web/api/v1/__init__.py
Normal file
0
src/web/api/v1/__init__.py
Normal file
38
src/web/api/v1/deps.py
Normal file
38
src/web/api/v1/deps.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# src/web/api/deps.py
|
||||
from functools import lru_cache
|
||||
|
||||
from src.paper.state_store import StateStore
|
||||
|
||||
from .settings import settings
|
||||
from .providers.base import StateProvider
|
||||
from .providers.sqlite import SQLiteStateProvider
|
||||
from .providers.mock import MockStateProvider
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# StateStore singleton (solo para SQLite provider)
|
||||
# --------------------------------------------------
|
||||
@lru_cache(maxsize=1)
|
||||
def get_store() -> StateStore:
|
||||
"""
|
||||
Singleton del StateStore.
|
||||
La API es read-only, así que es seguro compartir conexión.
|
||||
"""
|
||||
return StateStore(settings.state_db_path)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# Provider selector
|
||||
# --------------------------------------------------
|
||||
def get_provider() -> StateProvider:
|
||||
"""
|
||||
Devuelve el provider adecuado según configuración.
|
||||
|
||||
- mock_mode = True → MockStateProvider
|
||||
- mock_mode = False → SQLiteStateProvider (real)
|
||||
"""
|
||||
if settings.mock_mode:
|
||||
return MockStateProvider()
|
||||
|
||||
store = get_store()
|
||||
return SQLiteStateProvider(store)
|
||||
80
src/web/api/v1/main.py
Normal file
80
src/web/api/v1/main.py
Normal file
@@ -0,0 +1,80 @@
|
||||
|
||||
# src/web/api/main.py
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .settings import settings
|
||||
|
||||
# Routers
|
||||
from .routers.health import router as health_router
|
||||
from .routers.bot import router as bot_router
|
||||
from .routers.equity import router as equity_router
|
||||
from .routers.trades import router as trades_router
|
||||
from .routers.metrics import router as metrics_router
|
||||
from .routers.events import router as events_router
|
||||
from .routers.positions import router as positions_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title=settings.api_title,
|
||||
version=settings.api_version,
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# CORS (solo lectura)
|
||||
# --------------------------------------------------
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Static files (UI)
|
||||
# --------------------------------------------------
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(directory="src/web/ui/static"),
|
||||
name="static",
|
||||
)
|
||||
|
||||
templates = Jinja2Templates(directory="src/web/ui/templates")
|
||||
|
||||
# --------------------------------------------------
|
||||
# UI routes
|
||||
# --------------------------------------------------
|
||||
@app.get("/")
|
||||
def dashboard(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
{"request": request},
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# API routers (versionados)
|
||||
# --------------------------------------------------
|
||||
api_prefix = settings.api_prefix
|
||||
|
||||
app.include_router(health_router, prefix=api_prefix)
|
||||
app.include_router(bot_router, prefix=api_prefix)
|
||||
app.include_router(equity_router, prefix=api_prefix)
|
||||
app.include_router(trades_router, prefix=api_prefix)
|
||||
app.include_router(metrics_router, prefix=api_prefix)
|
||||
app.include_router(events_router, prefix=api_prefix)
|
||||
# app.include_router(positions_router, prefix=api_prefix)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Instancia ASGI
|
||||
app = create_app()
|
||||
36
src/web/api/v1/providers/base.py
Normal file
36
src/web/api/v1/providers/base.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# src/web/api/providers/base.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class StateProvider(ABC):
|
||||
"""
|
||||
Contrato que define qué datos expone el bot a la API.
|
||||
La UI y los routers dependen SOLO de esto.
|
||||
"""
|
||||
|
||||
# -----------------------------
|
||||
# Core state
|
||||
# -----------------------------
|
||||
@abstractmethod
|
||||
def get_broker(self) -> Optional[Dict[str, Any]]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_loop(self) -> Optional[Dict[str, Any]]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_metrics(self) -> Optional[Dict[str, Any]]:
|
||||
...
|
||||
|
||||
# -----------------------------
|
||||
# Trades
|
||||
# -----------------------------
|
||||
@abstractmethod
|
||||
def list_trades(
|
||||
self,
|
||||
limit: int = 200,
|
||||
symbol: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
...
|
||||
61
src/web/api/v1/providers/mock.py
Normal file
61
src/web/api/v1/providers/mock.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# src/web/api/providers/mock.py
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from .base import StateProvider
|
||||
|
||||
|
||||
class MockStateProvider(StateProvider):
|
||||
"""
|
||||
Provider mock para desarrollo de UI sin bot corriendo.
|
||||
"""
|
||||
|
||||
def _now(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# -----------------------------
|
||||
# Core state
|
||||
# -----------------------------
|
||||
def get_broker(self) -> Optional[Dict[str, Any]]:
|
||||
equity = 10_000 + random.uniform(-300, 600)
|
||||
cash = 4_000 + random.uniform(-100, 100)
|
||||
|
||||
return {
|
||||
"initial_cash": 10_000,
|
||||
"cash": cash,
|
||||
"equity": equity,
|
||||
"realized_pnl": equity - 10_000,
|
||||
"positions": {},
|
||||
"last_price": {"BTC/USDT": 42_000 + random.uniform(-200, 200)},
|
||||
"trades_count": random.randint(5, 25),
|
||||
"updated_at": self._now(),
|
||||
}
|
||||
|
||||
def get_loop(self) -> Optional[Dict[str, Any]]:
|
||||
curve = [10_000 + i * 3 for i in range(120)]
|
||||
return {
|
||||
"last_ts": self._now(),
|
||||
"equity_curve": curve,
|
||||
"equity_timestamps": [],
|
||||
}
|
||||
|
||||
def get_metrics(self) -> Optional[Dict[str, Any]]:
|
||||
return {
|
||||
"cagr": 0.21,
|
||||
"max_drawdown": -0.14,
|
||||
"calmar_ratio": 1.5,
|
||||
"volatility": 0.28,
|
||||
"time_in_drawdown": 0.39,
|
||||
"ulcer_index": 7.4,
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# Trades
|
||||
# -----------------------------
|
||||
def list_trades(
|
||||
self,
|
||||
limit: int = 200,
|
||||
symbol: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
72
src/web/api/v1/providers/sqlite.py
Normal file
72
src/web/api/v1/providers/sqlite.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# src/web/api/providers/sqlite.py
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.paper.state_store import StateStore
|
||||
from .base import StateProvider
|
||||
|
||||
|
||||
class SQLiteStateProvider(StateProvider):
|
||||
"""
|
||||
Provider real que lee del StateStore (SQLite).
|
||||
Read-only.
|
||||
"""
|
||||
|
||||
def __init__(self, store: StateStore):
|
||||
self.store = store
|
||||
|
||||
# -----------------------------
|
||||
# Core state
|
||||
# -----------------------------
|
||||
def get_broker(self) -> Optional[Dict[str, Any]]:
|
||||
return self.store.load_broker_snapshot()
|
||||
|
||||
def get_loop(self) -> Optional[Dict[str, Any]]:
|
||||
return self.store.load_loop_state()
|
||||
|
||||
def get_metrics(self) -> Optional[Dict[str, Any]]:
|
||||
# opcional, por ahora puede devolver None
|
||||
return None
|
||||
|
||||
# -----------------------------
|
||||
# Equity
|
||||
# -----------------------------
|
||||
def get_equity_state(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Estado actual de equity (último snapshot).
|
||||
"""
|
||||
broker = self.store.load_broker_snapshot() or {}
|
||||
|
||||
return {
|
||||
"equity": broker.get("equity"),
|
||||
"cash": broker.get("cash"),
|
||||
"updated_at": broker.get("updated_at"),
|
||||
}
|
||||
|
||||
def get_equity_curve(self, range: str) -> Dict[str, List]:
|
||||
"""
|
||||
Serie temporal de equity filtrada por rango.
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
from_ts = None
|
||||
if range == "1h":
|
||||
from_ts = now - timedelta(hours=1)
|
||||
elif range == "6h":
|
||||
from_ts = now - timedelta(hours=6)
|
||||
elif range == "24h":
|
||||
from_ts = now - timedelta(hours=24)
|
||||
elif range == "all":
|
||||
from_ts = None
|
||||
|
||||
return self.store.load_equity_curve(from_ts=from_ts)
|
||||
|
||||
# -----------------------------
|
||||
# Trades
|
||||
# -----------------------------
|
||||
def list_trades(
|
||||
self,
|
||||
limit: int = 200,
|
||||
symbol: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
return self.store.load_trades(limit=limit)
|
||||
39
src/web/api/v1/routers/bot.py
Normal file
39
src/web/api/v1/routers/bot.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# src/web/api/routers/bot.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.web.api.deps import get_provider
|
||||
from src.web.api.providers.base import StateProvider
|
||||
from src.web.api.settings import settings
|
||||
|
||||
router = APIRouter(prefix="/bot", tags=["bot"])
|
||||
|
||||
|
||||
def _parse_iso(ts: str) -> datetime:
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def bot_status(provider: StateProvider = Depends(get_provider)):
|
||||
broker = provider.get_broker()
|
||||
loop = provider.get_loop()
|
||||
|
||||
if not broker:
|
||||
return {
|
||||
"state": "INIT",
|
||||
"heartbeat_ts": None,
|
||||
"last_ts": loop.get("last_ts") if loop else None,
|
||||
}
|
||||
|
||||
heartbeat_ts = broker.get("updated_at")
|
||||
age = (
|
||||
datetime.now(timezone.utc) - _parse_iso(heartbeat_ts)
|
||||
).total_seconds()
|
||||
|
||||
state = "RUNNING" if age <= settings.heartbeat_stale_seconds else "ERROR"
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"heartbeat_ts": heartbeat_ts,
|
||||
"last_ts": loop.get("last_ts") if loop else None,
|
||||
}
|
||||
41
src/web/api/v1/routers/equity.py
Normal file
41
src/web/api/v1/routers/equity.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# src/web/api/routers/equity.py
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from src.web.api.v1.deps import get_provider
|
||||
from src.web.api.v1.providers.base import StateProvider
|
||||
|
||||
router = APIRouter(prefix="/equity", tags=["equity"])
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# Equity state (KPIs)
|
||||
# --------------------------------------------------
|
||||
@router.get("/state")
|
||||
def equity_state(provider: StateProvider = Depends(get_provider)):
|
||||
broker = provider.get_broker() or {}
|
||||
|
||||
equity = broker.get("equity")
|
||||
cash = broker.get("cash")
|
||||
realized_pnl = broker.get("realized_pnl")
|
||||
updated_at = broker.get("updated_at")
|
||||
|
||||
return {
|
||||
"cash": cash,
|
||||
"equity": equity,
|
||||
"realized_pnl": realized_pnl,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# Equity curve (TIME SERIES)
|
||||
# --------------------------------------------------
|
||||
@router.get("/curve")
|
||||
def equity_curve(
|
||||
range: str = Query("all", pattern="^(1h|6h|24h|all)$"),
|
||||
provider: StateProvider = Depends(get_provider),
|
||||
):
|
||||
"""
|
||||
Devuelve la equity curve filtrada por rango temporal.
|
||||
"""
|
||||
return provider.get_equity_curve(range=range)
|
||||
40
src/web/api/v1/routers/events.py
Normal file
40
src/web/api/v1/routers/events.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# src/web/api/routers/events.py
|
||||
from fastapi import APIRouter, Query
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
|
||||
def _tail_file(path: Path, n: int) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
dq = deque(maxlen=n)
|
||||
with path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
dq.append(line.rstrip())
|
||||
return list(dq)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def events(
|
||||
limit: int = Query(200, ge=1, le=2000),
|
||||
kind: str = Query("trading", pattern="^(trading|errors)$"),
|
||||
):
|
||||
logs_dir = Path("logs")
|
||||
pattern = "trading_bot_*.log" if kind == "trading" else "errors_*.log"
|
||||
|
||||
files = sorted(
|
||||
logs_dir.glob(pattern),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if not files:
|
||||
return {"items": []}
|
||||
|
||||
lines = _tail_file(files[0], limit)
|
||||
return {
|
||||
"items": lines,
|
||||
"file": files[0].name,
|
||||
}
|
||||
20
src/web/api/v1/routers/health.py
Normal file
20
src/web/api/v1/routers/health.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# src/web/api/routers/heallth.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from src.web.api.deps import get_provider
|
||||
from src.web.api.providers.base import StateProvider
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(provider: StateProvider = Depends(get_provider)):
|
||||
broker = provider.get_broker()
|
||||
loop = provider.get_loop()
|
||||
metrics = provider.get_metrics()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"has_broker": broker is not None,
|
||||
"has_loop": loop is not None,
|
||||
"has_metrics": metrics is not None,
|
||||
}
|
||||
11
src/web/api/v1/routers/metrics.py
Normal file
11
src/web/api/v1/routers/metrics.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# src/web/api/routers/metrics.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from src.web.api.deps import get_provider
|
||||
from src.web.api.providers.base import StateProvider
|
||||
|
||||
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def metrics(provider: StateProvider = Depends(get_provider)):
|
||||
return provider.get_metrics() or {}
|
||||
25
src/web/api/v1/routers/positions.py
Normal file
25
src/web/api/v1/routers/positions.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# src/web/api/routers/positions.py
|
||||
# src/web/api/routers/positions.py
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from src.web.api.deps import get_provider
|
||||
from src.web.api.providers.base import StateProvider
|
||||
|
||||
router = APIRouter(prefix="/positions", tags=["positions"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def positions(provider: StateProvider = Depends(get_provider)):
|
||||
broker = provider.get_broker() or {}
|
||||
positions = broker.get("positions") or {}
|
||||
|
||||
out = []
|
||||
for _, p in positions.items():
|
||||
qty = float(p.get("qty", 0.0))
|
||||
if qty > 0:
|
||||
out.append(p)
|
||||
|
||||
return {
|
||||
"items": out,
|
||||
"updated_at": broker.get("updated_at"),
|
||||
}
|
||||
21
src/web/api/v1/routers/trades.py
Normal file
21
src/web/api/v1/routers/trades.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# src/web/api/routers/trades.py
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional
|
||||
|
||||
from src.web.api.deps import get_provider
|
||||
from src.web.api.providers.base import StateProvider
|
||||
|
||||
router = APIRouter(prefix="/trades", tags=["trades"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_trades(
|
||||
limit: int = Query(200, ge=1, le=5000),
|
||||
symbol: Optional[str] = None,
|
||||
provider: StateProvider = Depends(get_provider),
|
||||
):
|
||||
trades = provider.list_trades(limit=limit, symbol=symbol)
|
||||
return {
|
||||
"items": trades,
|
||||
"count": len(trades),
|
||||
}
|
||||
47
src/web/api/v1/settings.py
Normal file
47
src/web/api/v1/settings.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# src/web/api/settings.py
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
# src/web/api/settings.py → api → web → src → trading-bot
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
# --------------------------------------------------
|
||||
# API
|
||||
# --------------------------------------------------
|
||||
api_prefix: str = "/api/v1"
|
||||
api_title: str = "Trading Bot API"
|
||||
api_version: str = "1.0.0"
|
||||
|
||||
# --------------------------------------------------
|
||||
# Data source
|
||||
# --------------------------------------------------
|
||||
state_db_path: Path = PROJECT_ROOT / "data/paper_trading/state.db"
|
||||
|
||||
# --------------------------------------------------
|
||||
# Runtime behaviour
|
||||
# --------------------------------------------------
|
||||
heartbeat_stale_seconds: int = 180 # si no hay heartbeat → ERROR
|
||||
mock_mode: bool = False
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""
|
||||
Carga settings desde ENV sin romper defaults.
|
||||
"""
|
||||
return Settings(
|
||||
state_db_path=Path(
|
||||
os.getenv("STATE_DB_PATH",
|
||||
PROJECT_ROOT / "data/paper_trading/state.db"
|
||||
)
|
||||
),
|
||||
heartbeat_stale_seconds=int(
|
||||
os.getenv("HEARTBEAT_STALE_SECONDS", 180)
|
||||
),
|
||||
mock_mode=os.getenv("MOCK_MODE", "false").lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
settings = load_settings()
|
||||
0
src/web/api/v1/utils/http_cache.py
Normal file
0
src/web/api/v1/utils/http_cache.py
Normal file
0
src/web/api/v2/__init__.py
Normal file
0
src/web/api/v2/__init__.py
Normal file
0
src/web/api/v2/deps.py
Normal file
0
src/web/api/v2/deps.py
Normal file
107
src/web/api/v2/main.py
Normal file
107
src/web/api/v2/main.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# src/web/api/v2/main.py
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import time
|
||||
|
||||
from .settings import settings
|
||||
from src.web.api.v2.routers.calibration_data import router as calibration_data_router
|
||||
|
||||
# --------------------------------------------------
|
||||
# Logging
|
||||
# --------------------------------------------------
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("tradingbot.api.v2")
|
||||
|
||||
# --------------------------------------------------
|
||||
# Base paths
|
||||
# --------------------------------------------------
|
||||
PROJECT_ROOT = settings.project_root
|
||||
UI_ROOT = PROJECT_ROOT / "src/web/ui/v2"
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
# --------------------------------------------------
|
||||
# FastAPI app
|
||||
# --------------------------------------------------
|
||||
app = FastAPI(
|
||||
title=settings.api_title,
|
||||
version=settings.api_version,
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Middleware: request/response logging
|
||||
# --------------------------------------------------
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
logger.info("➡️ %s %s", request.method, request.url.path)
|
||||
response = await call_next(request)
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
logger.info(
|
||||
"⬅️ %s %s -> %s (%.1f ms)",
|
||||
request.method,
|
||||
request.url.path,
|
||||
response.status_code,
|
||||
elapsed_ms,
|
||||
)
|
||||
return response
|
||||
|
||||
# -------------------------
|
||||
# Templates (UI v2)
|
||||
# -------------------------
|
||||
templates = Jinja2Templates(
|
||||
directory=str(UI_ROOT / "templates")
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# Static files (UI v2)
|
||||
# -------------------------
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(directory=str(UI_ROOT / "static")),
|
||||
name="static",
|
||||
)
|
||||
|
||||
# ==================================================
|
||||
# ROUTES — UI ONLY (TEMPORAL)
|
||||
# ==================================================
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def trading_dashboard(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"pages/trading/dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"page": "trading",
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/calibration/data", response_class=HTMLResponse)
|
||||
def calibration_data(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"pages/calibration/calibration_data.html",
|
||||
{
|
||||
"request": request,
|
||||
"page": "calibration",
|
||||
"step": 1,
|
||||
},
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# API routers (versionados)
|
||||
# --------------------------------------------------
|
||||
api_prefix = settings.api_prefix
|
||||
app.include_router(calibration_data_router, prefix=api_prefix)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ASGI app
|
||||
app = create_app()
|
||||
|
||||
|
||||
393
src/web/api/v2/routers/calibration_data.py
Normal file
393
src/web/api/v2/routers/calibration_data.py
Normal file
@@ -0,0 +1,393 @@
|
||||
# src/web/api/v2/routers/calibration_data.py
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.data.storage import StorageManager
|
||||
from src.data.downloader import OHLCVDownloader
|
||||
from src.data.download_job import DownloadJob
|
||||
|
||||
from ..schemas.calibration_data import (
|
||||
CalibrationDataRequest,
|
||||
CalibrationDataResponse,
|
||||
CalibrationDataDownloadRequest,
|
||||
CalibrationDataDownloadResponse,
|
||||
CalibrationDataDownloadJobStartRequest,
|
||||
CalibrationDataDownloadJobStartResponse,
|
||||
CalibrationDataDownloadJobStatusResponse,
|
||||
CalibrationDataDownloadJobCancelResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("tradingbot.api.v2")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/calibration/data",
|
||||
tags=["calibration"],
|
||||
)
|
||||
|
||||
# =================================================
|
||||
# In-memory job store (Option A)
|
||||
# =================================================
|
||||
|
||||
DOWNLOAD_JOBS: Dict[str, DownloadJob] = {}
|
||||
DOWNLOAD_JOBS_LOCK = threading.Lock()
|
||||
|
||||
# =================================================
|
||||
# Dependencies
|
||||
# =================================================
|
||||
|
||||
def get_storage() -> StorageManager:
|
||||
return StorageManager.from_env()
|
||||
|
||||
|
||||
def get_downloader() -> OHLCVDownloader:
|
||||
return OHLCVDownloader(exchange_name="binance")
|
||||
|
||||
|
||||
# =================================================
|
||||
# Helpers
|
||||
# =================================================
|
||||
|
||||
def _db_summary(
|
||||
storage: StorageManager,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
):
|
||||
query = """
|
||||
SELECT
|
||||
MIN(timestamp) AS first_ts,
|
||||
MAX(timestamp) AS last_ts,
|
||||
COUNT(*) AS candles
|
||||
FROM ohlcv
|
||||
WHERE symbol = :symbol AND timeframe = :timeframe
|
||||
"""
|
||||
|
||||
params = {"symbol": symbol, "timeframe": timeframe}
|
||||
|
||||
if start_date:
|
||||
query += " AND timestamp >= :start_date"
|
||||
params["start_date"] = start_date
|
||||
|
||||
if end_date:
|
||||
query += " AND timestamp <= :end_date"
|
||||
params["end_date"] = end_date
|
||||
|
||||
with storage.engine.connect() as conn:
|
||||
row = conn.execute(text(query), params).mappings().fetchone()
|
||||
|
||||
if not row:
|
||||
return {"first_ts": None, "last_ts": None, "candles": 0}
|
||||
|
||||
return {
|
||||
"first_ts": row["first_ts"],
|
||||
"last_ts": row["last_ts"],
|
||||
"candles": int(row["candles"] or 0),
|
||||
}
|
||||
|
||||
def analyze_data_quality(df: pd.DataFrame, timeframe: str):
|
||||
# --- timeframe a timedelta ---
|
||||
tf_map = {
|
||||
"1m": timedelta(minutes=1),
|
||||
"5m": timedelta(minutes=5),
|
||||
"15m": timedelta(minutes=15),
|
||||
"30m": timedelta(minutes=30),
|
||||
"1h": timedelta(hours=1),
|
||||
"4h": timedelta(hours=4),
|
||||
"1d": timedelta(days=1),
|
||||
}
|
||||
tf_delta = tf_map.get(timeframe)
|
||||
if not tf_delta or df.empty:
|
||||
return None
|
||||
|
||||
# --- continuidad / gaps ---
|
||||
diffs = df.index.to_series().diff().dropna()
|
||||
gap_threshold_warn = tf_delta * 1.5
|
||||
gap_threshold_fail = tf_delta * 5
|
||||
|
||||
big_gaps = diffs[diffs > gap_threshold_warn]
|
||||
max_gap = diffs.max()
|
||||
|
||||
continuity = "ok"
|
||||
if any(diffs > gap_threshold_fail):
|
||||
continuity = "fail"
|
||||
elif len(big_gaps) > 0:
|
||||
continuity = "warning"
|
||||
|
||||
# --- cobertura ---
|
||||
start, end = df.index.min(), df.index.max()
|
||||
expected = int((end - start) / tf_delta) + 1
|
||||
actual = len(df)
|
||||
ratio = actual / expected if expected > 0 else 0
|
||||
|
||||
coverage_status = "ok"
|
||||
if ratio < 0.98:
|
||||
coverage_status = "fail"
|
||||
elif ratio < 0.995:
|
||||
coverage_status = "warning"
|
||||
|
||||
# --- volumen ---
|
||||
zero_vol_ratio = (df["volume"] == 0).mean()
|
||||
volume_status = "ok"
|
||||
if zero_vol_ratio > 0.01:
|
||||
volume_status = "fail"
|
||||
elif zero_vol_ratio > 0.001:
|
||||
volume_status = "warning"
|
||||
|
||||
# --- estado global ---
|
||||
statuses = [continuity, coverage_status, volume_status]
|
||||
if "fail" in statuses:
|
||||
status = "fail"
|
||||
msg = "Datos incompletos. Se recomienda revisar o volver a descargar."
|
||||
elif "warning" in statuses:
|
||||
status = "warning"
|
||||
msg = "Datos utilizables con pequeñas discontinuidades."
|
||||
else:
|
||||
status = "ok"
|
||||
msg = "Datos continuos y completos. Aptos para calibración."
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"checks": {
|
||||
"continuity": continuity,
|
||||
"gaps": {
|
||||
"count": int(len(big_gaps)),
|
||||
"max_gap": str(max_gap) if pd.notna(max_gap) else None,
|
||||
},
|
||||
"coverage": {
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
"ratio": round(ratio, 4),
|
||||
},
|
||||
"volume": volume_status,
|
||||
},
|
||||
"message": msg,
|
||||
}
|
||||
|
||||
# =================================================
|
||||
# INSPECT (DB)
|
||||
# =================================================
|
||||
|
||||
@router.post("/inspect", response_model=CalibrationDataResponse)
|
||||
def inspect_calibration_data(
|
||||
payload: CalibrationDataRequest,
|
||||
storage: StorageManager = Depends(get_storage),
|
||||
):
|
||||
summary = _db_summary(
|
||||
storage,
|
||||
payload.symbol,
|
||||
payload.timeframe,
|
||||
payload.start_date,
|
||||
payload.end_date,
|
||||
)
|
||||
|
||||
data_quality = None
|
||||
if summary["candles"] > 0:
|
||||
df = storage.load_ohlcv(
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
start_date=payload.start_date,
|
||||
end_date=payload.end_date,
|
||||
)
|
||||
data_quality = analyze_data_quality(df, payload.timeframe)
|
||||
|
||||
return CalibrationDataResponse(
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
first_available=summary["first_ts"],
|
||||
last_available=summary["last_ts"],
|
||||
candles_count=summary["candles"],
|
||||
valid=summary["candles"] > 0,
|
||||
data_quality=data_quality,
|
||||
)
|
||||
|
||||
|
||||
# =================================================
|
||||
# DOWNLOAD (SYNC / legacy)
|
||||
# =================================================
|
||||
|
||||
@router.post("/download", response_model=CalibrationDataDownloadResponse)
|
||||
def download_calibration_data(
|
||||
payload: CalibrationDataDownloadRequest,
|
||||
storage: StorageManager = Depends(get_storage),
|
||||
downloader: OHLCVDownloader = Depends(get_downloader),
|
||||
):
|
||||
logger.info("⬇️ HIT /calibration/data/download")
|
||||
|
||||
if payload.start_date and payload.end_date and payload.start_date > payload.end_date:
|
||||
raise HTTPException(400, "start_date no puede ser posterior a end_date")
|
||||
|
||||
before = _db_summary(
|
||||
storage,
|
||||
payload.symbol,
|
||||
payload.timeframe,
|
||||
payload.start_date,
|
||||
payload.end_date,
|
||||
)
|
||||
|
||||
if payload.dry_run:
|
||||
return CalibrationDataDownloadResponse(
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
start_date=payload.start_date,
|
||||
end_date=payload.end_date,
|
||||
started=False,
|
||||
dry_run=True,
|
||||
inserted_new_rows=0,
|
||||
first_available_after=before["first_ts"],
|
||||
last_available_after=before["last_ts"],
|
||||
candles_count_after=before["candles"],
|
||||
message="Dry-run: no se ha descargado nada",
|
||||
)
|
||||
|
||||
if before["candles"] == 0:
|
||||
df = downloader.download_full(
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
storage=storage,
|
||||
)
|
||||
else:
|
||||
if not payload.start_date or not payload.end_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"start_date y end_date son obligatorios cuando ya hay datos",
|
||||
)
|
||||
|
||||
df = downloader.download_range(
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
start=payload.start_date,
|
||||
end=payload.end_date,
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
inserted = storage.save_ohlcv(df)
|
||||
|
||||
after = _db_summary(
|
||||
storage,
|
||||
payload.symbol,
|
||||
payload.timeframe,
|
||||
payload.start_date,
|
||||
payload.end_date,
|
||||
)
|
||||
|
||||
return CalibrationDataDownloadResponse(
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
start_date=payload.start_date,
|
||||
end_date=payload.end_date,
|
||||
started=True,
|
||||
dry_run=False,
|
||||
inserted_new_rows=inserted,
|
||||
first_available_after=after["first_ts"],
|
||||
last_available_after=after["last_ts"],
|
||||
candles_count_after=after["candles"],
|
||||
message=f"Descarga completada. Filas nuevas: {inserted}",
|
||||
)
|
||||
|
||||
# =================================================
|
||||
# DOWNLOAD JOB (ASYNC + PROGRESS)
|
||||
# =================================================
|
||||
|
||||
def _run_download_job(
|
||||
job: DownloadJob,
|
||||
payload: CalibrationDataDownloadJobStartRequest,
|
||||
):
|
||||
storage = StorageManager.from_env()
|
||||
downloader = get_downloader()
|
||||
|
||||
try:
|
||||
job.update(status="downloading", message="Iniciando descarga")
|
||||
|
||||
df = downloader.download_range(
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
start=payload.start_date,
|
||||
end=payload.end_date,
|
||||
storage=storage,
|
||||
job=job,
|
||||
)
|
||||
|
||||
if job.cancelled:
|
||||
return
|
||||
|
||||
job.update(status="saving", message="Guardando en base de datos")
|
||||
|
||||
inserted = storage.save_ohlcv(df)
|
||||
|
||||
job.update(
|
||||
status="done",
|
||||
message=f"Descarga finalizada ({inserted} velas nuevas)",
|
||||
progress=100,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Error en job de descarga")
|
||||
job.update(status="failed", message=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/download/job",
|
||||
response_model=CalibrationDataDownloadJobStartResponse,
|
||||
)
|
||||
def start_download_job(
|
||||
payload: CalibrationDataDownloadJobStartRequest,
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
job = DownloadJob()
|
||||
|
||||
with DOWNLOAD_JOBS_LOCK:
|
||||
DOWNLOAD_JOBS[job.id] = job
|
||||
|
||||
job.update(
|
||||
status="created",
|
||||
message="Job creado",
|
||||
)
|
||||
|
||||
background.add_task(_run_download_job, job, payload)
|
||||
|
||||
return CalibrationDataDownloadJobStartResponse(
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
message=job.message,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/download/job/{job_id}",
|
||||
response_model=CalibrationDataDownloadJobStatusResponse,
|
||||
)
|
||||
def get_download_job_status(job_id: str):
|
||||
job = DOWNLOAD_JOBS.get(job_id)
|
||||
|
||||
if not job:
|
||||
raise HTTPException(404, "Job no encontrado")
|
||||
|
||||
return CalibrationDataDownloadJobStatusResponse(**job.as_dict())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/download/job/{job_id}/cancel",
|
||||
response_model=CalibrationDataDownloadJobCancelResponse,
|
||||
)
|
||||
def cancel_download_job(job_id: str):
|
||||
job = DOWNLOAD_JOBS.get(job_id)
|
||||
|
||||
if not job:
|
||||
raise HTTPException(404, "Job no encontrado")
|
||||
|
||||
job.cancel()
|
||||
|
||||
return CalibrationDataDownloadJobCancelResponse(
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
message=job.message,
|
||||
)
|
||||
186
src/web/api/v2/schemas/calibration_data.py
Normal file
186
src/web/api/v2/schemas/calibration_data.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# src/web/api/v2/schemas/calibration_data.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ==================================================
|
||||
# Inspect (DB)
|
||||
# ==================================================
|
||||
|
||||
class CalibrationDataRequest(BaseModel):
|
||||
symbol: str = Field(..., examples=["BTC/USDT"])
|
||||
timeframe: str = Field(..., examples=["1h"])
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class CalibrationDataResponse(BaseModel):
|
||||
symbol: str
|
||||
timeframe: str
|
||||
first_available: Optional[datetime]
|
||||
last_available: Optional[datetime]
|
||||
candles_count: int
|
||||
valid: bool
|
||||
|
||||
|
||||
# ==================================================
|
||||
# Download (Exchange -> DB) (modo "sync" / síncrono)
|
||||
# ==================================================
|
||||
|
||||
class CalibrationDataDownloadRequest(BaseModel):
|
||||
symbol: str = Field(..., examples=["BTC/USDT"])
|
||||
timeframe: str = Field(..., examples=["1h"])
|
||||
|
||||
# En esta primera versión, start/end sirven para:
|
||||
# - validar coherencia del input
|
||||
# - informar en logs/UI
|
||||
# pero el sync incremental REAL lo hace sync_ohlcv() desde last_ts hasta expected_last.
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
dry_run: bool = Field(
|
||||
False,
|
||||
description="Si True: no descarga; solo valida y devuelve resumen BEFORE/AFTER (sin cambios).",
|
||||
)
|
||||
|
||||
|
||||
class CalibrationDataDownloadResponse(BaseModel):
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
start_date: Optional[datetime]
|
||||
end_date: Optional[datetime]
|
||||
|
||||
started: bool
|
||||
dry_run: bool
|
||||
|
||||
inserted_new_rows: int
|
||||
|
||||
first_available_after: Optional[datetime]
|
||||
last_available_after: Optional[datetime]
|
||||
candles_count_after: int
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
# ==================================================
|
||||
# Download (Exchange -> DB) (modo JOB / asíncrono con progreso)
|
||||
# ==================================================
|
||||
|
||||
JobStatus = Literal[
|
||||
"created",
|
||||
"downloading",
|
||||
"processing",
|
||||
"saving",
|
||||
"done",
|
||||
"cancelled",
|
||||
"failed",
|
||||
]
|
||||
|
||||
class CalibrationDataDownloadJobStartRequest(BaseModel):
|
||||
"""
|
||||
Request para iniciar un job asíncrono.
|
||||
Recomendación: aquí SÍ pedimos start/end para poder estimar velas y limitar descarga.
|
||||
"""
|
||||
symbol: str = Field(..., examples=["BTC/USDT"])
|
||||
timeframe: str = Field(..., examples=["1h"])
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
dry_run: bool = Field(
|
||||
False,
|
||||
description="Si True: no ejecuta descarga; solo calcula/valida y crea un job 'done' inmediatamente.",
|
||||
)
|
||||
|
||||
|
||||
class CalibrationDataDownloadJobStartResponse(BaseModel):
|
||||
job_id: str
|
||||
status: JobStatus
|
||||
message: str
|
||||
|
||||
|
||||
class CalibrationDataDownloadJobStatusResponse(BaseModel):
|
||||
job_id: str
|
||||
|
||||
symbol: Optional[str] = None
|
||||
timeframe: Optional[str] = None
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
status: JobStatus
|
||||
message: str
|
||||
|
||||
# progreso visual
|
||||
progress: int = Field(0, ge=0, le=100)
|
||||
|
||||
# progreso por bloques
|
||||
blocks_done: int = 0
|
||||
blocks_total: Optional[int] = None
|
||||
|
||||
# métricas útiles para UI
|
||||
candles_downloaded: int = 0
|
||||
inserted_new_rows: int = 0
|
||||
|
||||
# control
|
||||
cancelled: bool = False
|
||||
|
||||
# timestamps opcionales (útil para debug/UX)
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CalibrationDataDownloadJobCancelResponse(BaseModel):
|
||||
job_id: str
|
||||
status: JobStatus
|
||||
message: str
|
||||
|
||||
# =========================
|
||||
# Inspect (DB)
|
||||
# =========================
|
||||
|
||||
class CalibrationDataRequest(BaseModel):
|
||||
symbol: str
|
||||
timeframe: str
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
|
||||
QualityStatus = Literal["ok", "warning", "fail"]
|
||||
|
||||
|
||||
class DataQualityGaps(BaseModel):
|
||||
count: int
|
||||
max_gap: Optional[str] = None
|
||||
|
||||
|
||||
class DataQualityCoverage(BaseModel):
|
||||
expected: int
|
||||
actual: int
|
||||
ratio: float
|
||||
|
||||
|
||||
class DataQualityChecks(BaseModel):
|
||||
continuity: QualityStatus
|
||||
gaps: DataQualityGaps
|
||||
coverage: DataQualityCoverage
|
||||
volume: QualityStatus
|
||||
|
||||
|
||||
class DataQualityResult(BaseModel):
|
||||
status: QualityStatus
|
||||
checks: DataQualityChecks
|
||||
message: str
|
||||
|
||||
|
||||
class CalibrationDataResponse(BaseModel):
|
||||
symbol: str
|
||||
timeframe: str
|
||||
first_available: Optional[datetime]
|
||||
last_available: Optional[datetime]
|
||||
candles_count: int
|
||||
valid: bool
|
||||
data_quality: Optional[DataQualityResult] = None
|
||||
42
src/web/api/v2/settings.py
Normal file
42
src/web/api/v2/settings.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# src/web/api/v2/settings.py
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
# settings.py → v2 → api → web → src → project root
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
# --------------------------------------------------
|
||||
# API
|
||||
# --------------------------------------------------
|
||||
api_prefix: str = "/api/v2"
|
||||
api_title: str = "Trading Bot API v2"
|
||||
api_version: str = "2.0.0"
|
||||
|
||||
# --------------------------------------------------
|
||||
# Paths
|
||||
# --------------------------------------------------
|
||||
project_root: Path = PROJECT_ROOT
|
||||
data_dir: Path = PROJECT_ROOT / "data"
|
||||
calibration_dir: Path = PROJECT_ROOT / "data" / "calibration"
|
||||
|
||||
# --------------------------------------------------
|
||||
# Data sources
|
||||
# --------------------------------------------------
|
||||
ohlcv_timeframes_supported: list[str] = ["1m", "5m", "15m", "30m", "1h", "4h", "1d"]
|
||||
|
||||
# --------------------------------------------------
|
||||
# Runtime behaviour
|
||||
# --------------------------------------------------
|
||||
debug: bool = False
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings(
|
||||
debug=os.getenv("DEBUG", "false").lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
settings = load_settings()
|
||||
0
src/web/ui/v1/__init__.py
Normal file
0
src/web/ui/v1/__init__.py
Normal file
9
src/web/ui/v1/static/css/tabler.min.css
vendored
Normal file
9
src/web/ui/v1/static/css/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
src/web/ui/v1/static/js/api.js
Normal file
7
src/web/ui/v1/static/js/api.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(`${API_BASE}${path}`);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.json();
|
||||
}
|
||||
306
src/web/ui/v1/static/js/dashboard.js
Normal file
306
src/web/ui/v1/static/js/dashboard.js
Normal file
@@ -0,0 +1,306 @@
|
||||
let equityChart = null;
|
||||
let showDrawdown = true;
|
||||
let currentRange = "all";
|
||||
|
||||
// --------------------------------------------------
|
||||
// STATUS
|
||||
// --------------------------------------------------
|
||||
async function updateStatus() {
|
||||
const data = await apiGet("/bot/status");
|
||||
const el = document.getElementById("bot-status");
|
||||
|
||||
el.textContent = data.state;
|
||||
el.className =
|
||||
"ms-auto badge " +
|
||||
(data.state === "RUNNING"
|
||||
? "bg-green"
|
||||
: data.state === "ERROR"
|
||||
? "bg-red"
|
||||
: "bg-secondary");
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// KPI
|
||||
// --------------------------------------------------
|
||||
async function updateEquity() {
|
||||
const data = await apiGet("/equity/state");
|
||||
document.getElementById("kpi-equity").textContent =
|
||||
data.equity?.toFixed(2) ?? "—";
|
||||
document.getElementById("kpi-pnl").textContent =
|
||||
data.realized_pnl?.toFixed(2) ?? "—";
|
||||
}
|
||||
|
||||
async function fetchTrades() {
|
||||
const data = await apiGet("/trades?limit=500");
|
||||
return data.items || [];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// MAIN CHART
|
||||
// --------------------------------------------------
|
||||
async function updateCurve() {
|
||||
const data = await apiGet(`/equity/curve?range=${currentRange}`);
|
||||
|
||||
const labels = data.timestamps;
|
||||
const equity = data.equity;
|
||||
const cash = data.cash;
|
||||
|
||||
// -----------------------------
|
||||
// Max Equity (for drawdown)
|
||||
// -----------------------------
|
||||
const maxEquityCurve = [];
|
||||
let runningMax = -Infinity;
|
||||
|
||||
for (let i = 0; i < equity.length; i++) {
|
||||
runningMax = Math.max(runningMax, equity[i]);
|
||||
maxEquityCurve.push(runningMax);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Max Drawdown KPI
|
||||
// -----------------------------
|
||||
let maxDD = 0;
|
||||
for (let i = 0; i < equity.length; i++) {
|
||||
const dd = (equity[i] / maxEquityCurve[i] - 1) * 100;
|
||||
maxDD = Math.min(maxDD, dd);
|
||||
}
|
||||
|
||||
const elDD = document.getElementById("kpi-max-dd");
|
||||
if (elDD) elDD.textContent = `${maxDD.toFixed(2)} %`;
|
||||
|
||||
// -----------------------------
|
||||
// Trades → markers
|
||||
// -----------------------------
|
||||
const trades = await fetchTrades();
|
||||
const buyPoints = [];
|
||||
const sellPoints = [];
|
||||
|
||||
const minEquity = Math.min(...equity);
|
||||
const maxEquity = Math.max(...equity);
|
||||
const offset = Math.max((maxEquity - minEquity) * 0.05, 350);
|
||||
|
||||
trades.forEach(t => {
|
||||
if (!t.timestamp || !t.side) return;
|
||||
|
||||
const tradeTs = new Date(t.timestamp).getTime();
|
||||
let idx = labels.length - 1;
|
||||
|
||||
for (let i = labels.length - 1; i >= 0; i--) {
|
||||
if (new Date(labels[i]).getTime() <= tradeTs) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const y = equity[idx];
|
||||
if (y == null) return;
|
||||
|
||||
if (t.side === "BUY") {
|
||||
buyPoints.push({ x: labels[idx], y: y - offset, trade: t });
|
||||
}
|
||||
|
||||
if (t.side === "SELL" || t.side === "CLOSE") {
|
||||
sellPoints.push({ x: labels[idx], y: y + offset, trade: t });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------
|
||||
// INIT CHART
|
||||
// --------------------------------------------------
|
||||
if (!equityChart) {
|
||||
const ctx = document.getElementById("equityChart").getContext("2d");
|
||||
|
||||
equityChart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Max Equity",
|
||||
data: maxEquityCurve,
|
||||
borderColor: "rgba(0,0,0,0)",
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
label: "Equity",
|
||||
data: equity,
|
||||
borderColor: "#206bc4",
|
||||
backgroundColor: "rgba(214,57,57,0.15)",
|
||||
pointRadius: 0,
|
||||
fill: {
|
||||
target: 0,
|
||||
above: "rgba(0,0,0,0)",
|
||||
below: "rgba(214,57,57,0.15)"
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Cash",
|
||||
data: cash,
|
||||
borderColor: "#2fb344",
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
label: "BUY",
|
||||
data: buyPoints,
|
||||
pointStyle: "triangle",
|
||||
pointRotation: 0,
|
||||
pointRadius: 6,
|
||||
backgroundColor: "#2fb344"
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
label: "SELL",
|
||||
data: sellPoints,
|
||||
pointStyle: "triangle",
|
||||
pointRotation: 180,
|
||||
pointRadius: 6,
|
||||
backgroundColor: "#d63939"
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
animation: false,
|
||||
|
||||
// -----------------------------
|
||||
// SOLUCIÓN 1: TIME SCALE
|
||||
// -----------------------------
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: {
|
||||
tooltipFormat: "yyyy-MM-dd HH:mm",
|
||||
displayFormats: {
|
||||
minute: "HH:mm",
|
||||
hour: "HH:mm",
|
||||
day: "MMM dd"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------
|
||||
// SOLUCIÓN 2: ZOOM + PAN
|
||||
// -----------------------------
|
||||
plugins: {
|
||||
zoom: {
|
||||
limits: {
|
||||
x: { min: "original", max: "original" }
|
||||
},
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: "x",
|
||||
modifierKey: "ctrl"
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: "x"
|
||||
}
|
||||
},
|
||||
|
||||
// -----------------------------
|
||||
// SOLUCIÓN 3: DECIMATION
|
||||
// -----------------------------
|
||||
decimation: {
|
||||
enabled: true,
|
||||
algorithm: "lttb",
|
||||
samples: 500
|
||||
},
|
||||
|
||||
// -----------------------------
|
||||
// LEYENDA
|
||||
// -----------------------------
|
||||
legend: {
|
||||
display: true,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
generateLabels(chart) {
|
||||
return chart.data.datasets
|
||||
// ❌ fuera Max Equity
|
||||
.map((ds, i) => ({ ds, i }))
|
||||
.filter(({ ds }) => ds.label !== "Max Equity")
|
||||
.map(({ ds, i }) => {
|
||||
const isScatter = ds.type === "scatter";
|
||||
|
||||
return {
|
||||
text: ds.label,
|
||||
datasetIndex: i,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
|
||||
// 🎨 colores reales del dataset
|
||||
fillStyle: isScatter ? ds.backgroundColor : ds.borderColor,
|
||||
strokeStyle: ds.borderColor,
|
||||
|
||||
// 📏 línea vs punto
|
||||
lineWidth: isScatter ? 0 : 3,
|
||||
borderDash: ds.borderDash || [],
|
||||
|
||||
// 🔺 BUY / SELL = triángulos reales
|
||||
pointStyle: isScatter ? ds.pointStyle : "line",
|
||||
rotation: isScatter ? ds.pointRotation || 0 : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
equityChart.data.labels = labels;
|
||||
equityChart.data.datasets[0].data = maxEquityCurve;
|
||||
equityChart.data.datasets[1].data = equity;
|
||||
equityChart.data.datasets[2].data = cash;
|
||||
equityChart.data.datasets[3].data = buyPoints;
|
||||
equityChart.data.datasets[4].data = sellPoints;
|
||||
equityChart.update("none");
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// SOLUCIÓN 4: TIME WINDOWS
|
||||
// --------------------------------------------------
|
||||
function setTimeWindow(hours) {
|
||||
if (!equityChart) return;
|
||||
|
||||
if (hours === "ALL") {
|
||||
equityChart.options.scales.x.min = undefined;
|
||||
equityChart.options.scales.x.max = undefined;
|
||||
} else {
|
||||
const now = Date.now();
|
||||
equityChart.options.scales.x.min = now - hours * 3600_000;
|
||||
equityChart.options.scales.x.max = now;
|
||||
}
|
||||
|
||||
equityChart.update();
|
||||
}
|
||||
|
||||
async function updateEvents() {
|
||||
const data = await apiGet("/events?limit=20");
|
||||
document.getElementById("events-log").textContent =
|
||||
data.items.join("\n");
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// UI
|
||||
// --------------------------------------------------
|
||||
document.getElementById("reset-zoom")?.addEventListener("click", () => {
|
||||
equityChart?.resetZoom();
|
||||
});
|
||||
|
||||
document.getElementById("toggle-dd")?.addEventListener("change", e => {
|
||||
showDrawdown = e.target.checked;
|
||||
equityChart.data.datasets[1].fill = showDrawdown
|
||||
? { target: 0, above: "rgba(0,0,0,0)", below: "rgba(214,57,57,0.15)" }
|
||||
: false;
|
||||
equityChart.update("none");
|
||||
});
|
||||
|
||||
// --------------------------------------------------
|
||||
poll(updateStatus, 2000);
|
||||
poll(updateEquity, 5000);
|
||||
poll(updateCurve, 10000);
|
||||
poll(updateEvents, 10000);
|
||||
4
src/web/ui/v1/static/js/polling.js
Normal file
4
src/web/ui/v1/static/js/polling.js
Normal file
@@ -0,0 +1,4 @@
|
||||
function poll(fn, interval) {
|
||||
fn();
|
||||
return setInterval(fn, interval);
|
||||
}
|
||||
37
src/web/ui/v1/templates/base.html
Normal file
37
src/web/ui/v1/templates/base.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{% block title %}Trading Bot{% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/static/css/tabler.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
<header class="navbar navbar-expand-md navbar-light d-print-none">
|
||||
<div class="container-xl">
|
||||
<span class="navbar-brand">
|
||||
🤖 Trading Bot
|
||||
</span>
|
||||
<div id="bot-status" class="ms-auto badge bg-secondary">
|
||||
INIT
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="container-xl">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/polling.js"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
76
src/web/ui/v1/templates/dashboard.html
Normal file
76
src/web/ui/v1/templates/dashboard.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row row-deck row-cards">
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card"><div class="card-body">
|
||||
<div class="subheader">Equity</div>
|
||||
<div id="kpi-equity" class="h1">—</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card"><div class="card-body">
|
||||
<div class="subheader">PnL</div>
|
||||
<div id="kpi-pnl" class="h1">—</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card"><div class="card-body">
|
||||
<div class="subheader">Max DD</div>
|
||||
<div id="kpi-max-dd" class="h1">—</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- CHART -->
|
||||
<div class="col-12">
|
||||
<div class="card position-relative">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Equity Curve</h3>
|
||||
|
||||
<div class="btn-group position-absolute top-0 end-0 m-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeWindow(1)">1h</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeWindow(6)">6h</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeWindow(24)">24h</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeWindow('ALL')">ALL</button>
|
||||
<button id="reset-zoom" class="btn btn-sm btn-outline-secondary">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch position-absolute top-20 start-50 m-2">
|
||||
<input class="form-check-input" type="checkbox" id="toggle-dd" checked>
|
||||
<label class="form-check-label">Drawdown</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<canvas id="equityChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EVENTS -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Events</h3></div>
|
||||
<div class="card-body">
|
||||
<pre id="events-log" style="max-height:200px; overflow:auto;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<!-- ✅ Time adapter necesario para scales.x.type="time" -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3"></script>
|
||||
<!-- ✅ Zoom plugin -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1"></script>
|
||||
<!-- Tu dashboard -->
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
{% endblock %}
|
||||
0
src/web/ui/v2/__init__.py
Normal file
0
src/web/ui/v2/__init__.py
Normal file
0
src/web/ui/v2/index.html
Normal file
0
src/web/ui/v2/index.html
Normal file
0
src/web/ui/v2/static/assets/logo.svg
Normal file
0
src/web/ui/v2/static/assets/logo.svg
Normal file
0
src/web/ui/v2/static/css/base.css
Normal file
0
src/web/ui/v2/static/css/base.css
Normal file
0
src/web/ui/v2/static/css/components.css
Normal file
0
src/web/ui/v2/static/css/components.css
Normal file
0
src/web/ui/v2/static/css/layout.css
Normal file
0
src/web/ui/v2/static/css/layout.css
Normal file
0
src/web/ui/v2/static/js/api.js
Normal file
0
src/web/ui/v2/static/js/api.js
Normal file
0
src/web/ui/v2/static/js/app.js
Normal file
0
src/web/ui/v2/static/js/app.js
Normal file
266
src/web/ui/v2/static/js/pages/calibration_data.js
Normal file
266
src/web/ui/v2/static/js/pages/calibration_data.js
Normal file
@@ -0,0 +1,266 @@
|
||||
// src/web/ui/v2/static/js/pages/calibration_data.js
|
||||
|
||||
console.log(
|
||||
"[calibration_data] script loaded ✅",
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
let currentDownloadJobId = null;
|
||||
let downloadPollTimer = null;
|
||||
|
||||
// =================================================
|
||||
// INSPECT DATA (DB)
|
||||
// =================================================
|
||||
|
||||
async function inspectCalibrationData() {
|
||||
console.log("[calibration_data] inspectCalibrationData() START ✅");
|
||||
|
||||
const symbol = document.getElementById("symbol")?.value;
|
||||
const timeframe = document.getElementById("timeframe")?.value;
|
||||
const start_date = document.getElementById("start_date")?.value || null;
|
||||
const end_date = document.getElementById("end_date")?.value || null;
|
||||
|
||||
const resultEl = document.getElementById("inspect-output");
|
||||
|
||||
const payload = { symbol, timeframe };
|
||||
if (start_date) payload.start_date = start_date;
|
||||
if (end_date) payload.end_date = end_date;
|
||||
|
||||
console.log("[calibration_data] inspect payload:", payload);
|
||||
|
||||
if (resultEl) {
|
||||
resultEl.textContent = "⏳ Inspeccionando datos en DB...";
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v2/calibration/data/inspect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
console.log("[calibration_data] inspect response:", data);
|
||||
|
||||
if (resultEl) {
|
||||
resultEl.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
renderDataSummary(data);
|
||||
|
||||
} catch (err) {
|
||||
console.error("[calibration_data] inspect FAILED", err);
|
||||
if (resultEl) {
|
||||
resultEl.textContent = "❌ Error inspeccionando datos";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// START DOWNLOAD JOB
|
||||
// =================================================
|
||||
|
||||
async function startDownloadJob() {
|
||||
console.log("[calibration_data] startDownloadJob()");
|
||||
|
||||
const symbol = document.getElementById("symbol")?.value;
|
||||
const timeframe = document.getElementById("timeframe")?.value;
|
||||
const start_date = document.getElementById("start_date")?.value || null;
|
||||
const end_date = document.getElementById("end_date")?.value || null;
|
||||
|
||||
const payload = { symbol, timeframe };
|
||||
if (start_date) payload.start_date = start_date;
|
||||
if (end_date) payload.end_date = end_date;
|
||||
|
||||
console.log("[calibration_data] download payload:", payload);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v2/calibration/data/download/job", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
console.log("[calibration_data] job created:", data);
|
||||
|
||||
currentDownloadJobId = data.job_id;
|
||||
|
||||
showDownloadProgress();
|
||||
pollDownloadStatus();
|
||||
|
||||
} catch (err) {
|
||||
console.error("[calibration_data] startDownloadJob FAILED", err);
|
||||
alert("Error iniciando la descarga");
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// POLLING STATUS
|
||||
// =================================================
|
||||
|
||||
async function pollDownloadStatus() {
|
||||
if (!currentDownloadJobId) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v2/calibration/data/download/job/${currentDownloadJobId}`
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
console.log("[calibration_data] job status:", data);
|
||||
|
||||
renderDownloadProgress(data);
|
||||
|
||||
if (
|
||||
data.status === "done" ||
|
||||
data.status === "failed" ||
|
||||
data.status === "cancelled"
|
||||
) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("[calibration_data] polling FAILED", err);
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
downloadPollTimer = setTimeout(pollDownloadStatus, 1500);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (downloadPollTimer) {
|
||||
clearTimeout(downloadPollTimer);
|
||||
downloadPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// CANCEL JOB
|
||||
// =================================================
|
||||
|
||||
async function cancelDownloadJob() {
|
||||
if (!currentDownloadJobId) return;
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`/api/v2/calibration/data/download/job/${currentDownloadJobId}/cancel`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[calibration_data] cancel FAILED", err);
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// RENDER UI
|
||||
// =================================================
|
||||
|
||||
function showDownloadProgress() {
|
||||
document.getElementById("download-progress-card")?.classList.remove("d-none");
|
||||
document.getElementById("download-progress-bar").style.width = "0%";
|
||||
document.getElementById("download-progress-text").textContent =
|
||||
"Iniciando descarga…";
|
||||
}
|
||||
|
||||
function renderDownloadProgress(job) {
|
||||
const bar = document.getElementById("download-progress-bar");
|
||||
const text = document.getElementById("download-progress-text");
|
||||
|
||||
if (!bar || !text) return;
|
||||
|
||||
bar.style.width = `${job.progress || 0}%`;
|
||||
bar.textContent = `${job.progress || 0}%`;
|
||||
text.textContent = job.message || job.status;
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// DATA SUMMARY (YA EXISTENTE)
|
||||
// =================================================
|
||||
|
||||
function renderDataSummary(data) {
|
||||
const card = document.getElementById("data-summary-card");
|
||||
if (!card) return;
|
||||
|
||||
card.classList.remove("d-none");
|
||||
|
||||
document.getElementById("first-ts").textContent =
|
||||
data.first_available ?? "–";
|
||||
document.getElementById("last-ts").textContent =
|
||||
data.last_available ?? "–";
|
||||
document.getElementById("candles-count").textContent =
|
||||
data.candles_count ?? 0;
|
||||
|
||||
const badge = document.getElementById("data-status-badge");
|
||||
const warning = document.getElementById("data-warning");
|
||||
const ok = document.getElementById("data-ok");
|
||||
const logEl = document.getElementById("data-log");
|
||||
|
||||
badge.className = "badge me-2";
|
||||
warning?.classList.add("d-none");
|
||||
ok?.classList.add("d-none");
|
||||
|
||||
if (!data.valid) {
|
||||
badge.classList.add("bg-warning");
|
||||
badge.textContent = "SIN DATOS";
|
||||
warning?.classList.remove("d-none");
|
||||
|
||||
if (logEl) {
|
||||
logEl.textContent =
|
||||
`❌ No hay datos para ${data.symbol} @ ${data.timeframe}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
badge.classList.add("bg-success");
|
||||
badge.textContent = "DATOS DISPONIBLES";
|
||||
ok?.classList.remove("d-none");
|
||||
|
||||
if (logEl) {
|
||||
logEl.textContent =
|
||||
`✅ Datos disponibles para ${data.symbol} @ ${data.timeframe}\n` +
|
||||
`Rango: ${data.first_available} → ${data.last_available}`;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// DATA QUALITY
|
||||
// -----------------------------
|
||||
const dqCard = document.getElementById("data-quality-card");
|
||||
if (dqCard && data.data_quality) {
|
||||
dqCard.classList.remove("d-none");
|
||||
|
||||
const dq = data.data_quality;
|
||||
document.getElementById("dq-status").textContent = dq.status.toUpperCase();
|
||||
document.getElementById("dq-message").textContent = dq.message;
|
||||
|
||||
document.getElementById("dq-continuity").textContent =
|
||||
dq.checks.continuity;
|
||||
document.getElementById("dq-gaps").textContent =
|
||||
`${dq.checks.gaps.count} (max ${dq.checks.gaps.max_gap ?? "–"})`;
|
||||
document.getElementById("dq-coverage").textContent =
|
||||
`${(dq.checks.coverage.ratio * 100).toFixed(2)}%`;
|
||||
document.getElementById("dq-volume").textContent =
|
||||
dq.checks.volume;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// INIT
|
||||
// =================================================
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
console.log("[calibration_data] DOMContentLoaded ✅");
|
||||
|
||||
document
|
||||
.getElementById("inspect-data-btn")
|
||||
?.addEventListener("click", inspectCalibrationData);
|
||||
|
||||
document
|
||||
.getElementById("download-data-btn")
|
||||
?.addEventListener("click", startDownloadJob);
|
||||
|
||||
document
|
||||
.getElementById("cancel-download-btn")
|
||||
?.addEventListener("click", cancelDownloadJob);
|
||||
});
|
||||
0
src/web/ui/v2/static/js/pages/dashboard.js
Normal file
0
src/web/ui/v2/static/js/pages/dashboard.js
Normal file
0
src/web/ui/v2/static/js/pages/positions.js
Normal file
0
src/web/ui/v2/static/js/pages/positions.js
Normal file
0
src/web/ui/v2/static/js/pages/trades.js
Normal file
0
src/web/ui/v2/static/js/pages/trades.js
Normal file
0
src/web/ui/v2/static/js/router.js
Normal file
0
src/web/ui/v2/static/js/router.js
Normal file
36
src/web/ui/v2/templates/base.html
Normal file
36
src/web/ui/v2/templates/base.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{% block title %}Trading Bot{% endblock %}</title>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Tabler CSS -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta19/dist/css/tabler.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- UI v2 base styles -->
|
||||
<link href="/static/css/base.css" rel="stylesheet" />
|
||||
<link href="/static/css/layout.css" rel="stylesheet" />
|
||||
<link href="/static/css/components.css" rel="stylesheet" />
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
<!-- Tabler JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta19/dist/js/tabler.min.js"></script>
|
||||
|
||||
<!-- UI v2 core JS -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/router.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
36
src/web/ui/v2/templates/layout.html
Normal file
36
src/web/ui/v2/templates/layout.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block body %}
|
||||
<div class="page">
|
||||
|
||||
<!-- Navbar -->
|
||||
{% include "partials/navbar.html" %}
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="col-12 col-md-3 col-lg-2">
|
||||
{% include "partials/sidebar.html" %}
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="col-12 col-md-9 col-lg-10 py-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{% include "partials/footer.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# -------------------------------------------------- #}
|
||||
{# Page-specific scripts (Step JS, etc.) #}
|
||||
{# -------------------------------------------------- #}
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
169
src/web/ui/v2/templates/pages/calibration/calibration_data.html
Normal file
169
src/web/ui/v2/templates/pages/calibration/calibration_data.html
Normal file
@@ -0,0 +1,169 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-xl">
|
||||
|
||||
<h2 class="mb-4">Calibración · Paso 1 · Datos</h2>
|
||||
|
||||
<!-- FORMULARIO -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Símbolo</label>
|
||||
<input id="symbol" class="form-control" value="BTC/USDT">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeframe</label>
|
||||
<select id="timeframe" class="form-select">
|
||||
<option value="1m">1m</option>
|
||||
<option value="5m">5m</option>
|
||||
<option value="15m">15m</option>
|
||||
<option value="30m">30m</option>
|
||||
<option value="1h" selected>1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="1d">1d</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Fecha inicio (opcional)</label>
|
||||
<input id="start_date" type="date" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Fecha fin (opcional)</label>
|
||||
<input id="end_date" type="date" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3 g-2">
|
||||
<div class="col-md-3">
|
||||
<button id="inspect-data-btn" type="button" class="btn btn-primary w-100">
|
||||
Inspeccionar datos (DB)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<button
|
||||
id="download-data-btn"
|
||||
type="button"
|
||||
class="btn btn-success w-100"
|
||||
>
|
||||
Descargar datos (Exchange)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<button
|
||||
id="cancel-download-btn"
|
||||
type="button"
|
||||
class="btn btn-outline-danger w-100"
|
||||
>
|
||||
Cancelar descarga
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger d-none mt-3" id="range-error">
|
||||
❌ El rango de fechas no es válido (la fecha de inicio es posterior a la de fin).
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RESUMEN DB -->
|
||||
<div class="card mt-4 d-none" id="data-summary-card">
|
||||
<div class="card-body">
|
||||
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<span id="data-status-badge" class="badge me-2"></span>
|
||||
<h4 class="card-title mb-0">Resumen de datos (Base de datos)</h4>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled mb-3">
|
||||
<li><strong>Primera vela disponible:</strong> <span id="first-ts">–</span></li>
|
||||
<li><strong>Última vela disponible:</strong> <span id="last-ts">–</span></li>
|
||||
<li><strong>Total de velas:</strong> <span id="candles-count">–</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="alert alert-warning d-none" id="data-warning">
|
||||
⚠️ No hay datos en base de datos para este rango.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success d-none" id="data-ok">
|
||||
✅ Datos encontrados en base de datos.
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="text-muted small mb-1">Log de inspección</div>
|
||||
<pre id="data-log" class="mb-0" style="white-space: pre-wrap;">–</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- DATA QUALITY -->
|
||||
<div class="card mt-4 d-none" id="data-quality-card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title mb-2">Data quality</h4>
|
||||
|
||||
<div class="mb-2">
|
||||
<strong>Status:</strong>
|
||||
<span id="dq-status" class="badge bg-secondary"></span>
|
||||
</div>
|
||||
|
||||
<p id="dq-message" class="mb-3 text-muted"></p>
|
||||
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li>Continuidad: <strong id="dq-continuity"></strong></li>
|
||||
<li>Gaps: <strong id="dq-gaps"></strong></li>
|
||||
<li>Cobertura: <strong id="dq-coverage"></strong></li>
|
||||
<li>Volumen: <strong id="dq-volume"></strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- PROGRESO DE DESCARGA -->
|
||||
<div class="card mt-4 d-none" id="download-progress-card">
|
||||
<div class="card-body">
|
||||
|
||||
<h4 class="card-title mb-3">Progreso de descarga</h4>
|
||||
|
||||
<div class="progress mb-2">
|
||||
<div
|
||||
id="download-progress-bar"
|
||||
class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: 0%"
|
||||
>
|
||||
0%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="download-progress-text" class="text-muted small">
|
||||
Esperando inicio de descarga…
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DEBUG -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Salida técnica (debug)</h4>
|
||||
<pre id="inspect-output" class="mt-3 text-muted">
|
||||
No se ha inspeccionado ningún dato todavía.
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/pages/calibration_data.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-xl">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="page-header d-print-none mb-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<h2 class="page-title">
|
||||
Calibration · Step 1 — Data
|
||||
</h2>
|
||||
<div class="text-muted mt-1">
|
||||
Select market data used for calibration
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data selection card -->
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Market data</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Symbol -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Symbol</label>
|
||||
<select class="form-select">
|
||||
<option selected>BTC/USDT</option>
|
||||
<option>ETH/USDT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Timeframe -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Timeframe</label>
|
||||
<select class="form-select">
|
||||
<option>1m</option>
|
||||
<option>5m</option>
|
||||
<option>15m</option>
|
||||
<option selected>1h</option>
|
||||
<option>4h</option>
|
||||
<option>1d</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Start date -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Start date</label>
|
||||
<input type="date" class="form-control">
|
||||
</div>
|
||||
|
||||
<!-- End date -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">End date</label>
|
||||
<input type="date" class="form-control">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-end">
|
||||
<a href="/calibration/step2" class="btn btn-primary">
|
||||
Continue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Help / info -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4>What happens in this step?</h4>
|
||||
<p class="text-muted">
|
||||
You define the historical market data that will be used to:
|
||||
</p>
|
||||
<ul class="text-muted">
|
||||
<li>download candles</li>
|
||||
<li>compute indicators</li>
|
||||
<li>calibrate strategies</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
69
src/web/ui/v2/templates/pages/trading/dashboard.html
Normal file
69
src/web/ui/v2/templates/pages/trading/dashboard.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-xl">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="page-header d-print-none mb-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<h2 class="page-title">
|
||||
Trading – Dashboard
|
||||
</h2>
|
||||
<div class="text-muted mt-1">
|
||||
Temporary overview (focus is on Calibration)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI row -->
|
||||
<div class="row row-deck row-cards mb-4">
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">Equity</div>
|
||||
<div class="h1">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">PnL</div>
|
||||
<div class="h1">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">Bot status</div>
|
||||
<div class="h1">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration CTA -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card card-md">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-3">Strategy Calibration</h3>
|
||||
<p class="text-muted mb-4">
|
||||
Configure data, stops, strategies and parameters step by step.
|
||||
</p>
|
||||
|
||||
<a href="/calibration/data" class="btn btn-primary btn-lg">
|
||||
Go to Calibration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
src/web/ui/v2/templates/pages/trading/trades.html
Normal file
0
src/web/ui/v2/templates/pages/trading/trades.html
Normal file
0
src/web/ui/v2/templates/partials/footer.html
Normal file
0
src/web/ui/v2/templates/partials/footer.html
Normal file
69
src/web/ui/v2/templates/partials/navbar.html
Normal file
69
src/web/ui/v2/templates/partials/navbar.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<header class="navbar navbar-expand-md navbar-light d-print-none">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Left side: toggle sidebar (mobile) -->
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#sidebar-menu">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<!-- Right side -->
|
||||
<div class="navbar-nav flex-row order-md-last ms-auto">
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Bot Status -->
|
||||
<!-- ========================= -->
|
||||
<div class="nav-item me-3 d-flex align-items-center">
|
||||
<span class="badge bg-secondary" id="bot-status">
|
||||
UNKNOWN
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Mode -->
|
||||
<!-- ========================= -->
|
||||
<div class="nav-item me-3 d-flex align-items-center">
|
||||
<span class="text-muted me-1">Mode:</span>
|
||||
<strong id="bot-mode">—</strong>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Latency -->
|
||||
<!-- ========================= -->
|
||||
<div class="nav-item me-3 d-flex align-items-center">
|
||||
<span class="text-muted me-1">Latency:</span>
|
||||
<span id="bot-latency">— ms</span>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Controls -->
|
||||
<!-- ========================= -->
|
||||
<div class="nav-item d-flex align-items-center gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
id="btn-start"
|
||||
title="Start bot"
|
||||
>
|
||||
▶ Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-warning"
|
||||
id="btn-pause"
|
||||
title="Pause bot"
|
||||
>
|
||||
⏸ Pause
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
id="btn-stop"
|
||||
title="Stop bot"
|
||||
>
|
||||
⏹ Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
93
src/web/ui/v2/templates/partials/sidebar.html
Normal file
93
src/web/ui/v2/templates/partials/sidebar.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Brand -->
|
||||
<h1 class="navbar-brand navbar-brand-autodark">
|
||||
<a href="/v2">
|
||||
<img
|
||||
src="/static/assets/logo.svg"
|
||||
alt="Trading Bot"
|
||||
class="navbar-brand-image"
|
||||
/>
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<!-- Sidebar menu -->
|
||||
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||
<ul class="navbar-nav pt-lg-3">
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Trading -->
|
||||
<!-- ========================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<!-- Icon: chart -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24"
|
||||
height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M4 18v-6"/>
|
||||
<path d="M8 18v-12"/>
|
||||
<path d="M12 18v-9"/>
|
||||
<path d="M16 18v-3"/>
|
||||
<path d="M20 18v-15"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-link-title">
|
||||
Trading
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Paper Trading -->
|
||||
<!-- ========================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/v2/paper">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<!-- Icon: flask -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24"
|
||||
height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M9 3h6"/>
|
||||
<path d="M10 9l-7 12a2 2 0 0 0 2 3h14a2 2 0 0 0 2 -3l-7 -12"/>
|
||||
<path d="M12 9v-6"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-link-title">
|
||||
Paper Trading
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Calibration -->
|
||||
<!-- ========================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/calibration/data">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<!-- Icon: settings -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24"
|
||||
height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06 .06a2 2 0 0 1 -2.83 2.83l-.06 -.06a1.65 1.65 0 0 0 -1.82 -.33a1.65 1.65 0 0 0 -1 1.51v.17a2 2 0 0 1 -4 0v-.17a1.65 1.65 0 0 0 -1 -1.51a1.65 1.65 0 0 0 -1.82 .33l-.06 .06a2 2 0 1 1 -2.83 -2.83l.06 -.06a1.65 1.65 0 0 0 .33 -1.82a1.65 1.65 0 0 0 -1.51 -1h-.17a2 2 0 0 1 0 -4h.17a1.65 1.65 0 0 0 1.51 -1a1.65 1.65 0 0 0 -.33 -1.82l-.06 -.06a2 2 0 1 1 2.83 -2.83l.06 .06a1.65 1.65 0 0 0 1.82 .33h.01a1.65 1.65 0 0 0 1 -1.51v-.17a2 2 0 0 1 4 0v.17a1.65 1.65 0 0 0 1 1.51a1.65 1.65 0 0 0 1.82 -.33l.06 -.06a2 2 0 1 1 2.83 2.83l-.06 .06a1.65 1.65 0 0 0 -.33 1.82v.01a1.65 1.65 0 0 0 1.51 1h.17a2 2 0 0 1 0 4h-.17a1.65 1.65 0 0 0 -1.51 1z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-link-title">
|
||||
Calibration
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
Reference in New Issue
Block a user