feat(calibration): complete step 1 data inspection with data quality v1

This commit is contained in:
DaM
2026-02-08 22:29:09 +01:00
parent f85c522f22
commit 4d769af8bf
89 changed files with 5014 additions and 203 deletions

View File

38
src/web/api/v1/deps.py Normal file
View 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
View 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()

View 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]]:
...

View 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 []

View 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)

View 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,
}

View 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)

View 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,
}

View 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,
}

View 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 {}

View 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"),
}

View 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),
}

View 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()

View File

View File

0
src/web/api/v2/deps.py Normal file
View File

107
src/web/api/v2/main.py Normal file
View 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()

View 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,
)

View 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

View 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()

View File

File diff suppressed because one or more lines are too long

View 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();
}

View 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);

View File

@@ -0,0 +1,4 @@
function poll(fn, interval) {
fn();
return setInterval(fn, interval);
}

View 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>

View 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 %}

View File

0
src/web/ui/v2/index.html Normal file
View File

View File

View File

View File

View File

View File

View File

View 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);
});

View File

View File

View 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>

View 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 %}

View 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 %}

View File

@@ -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 %}

View 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 %}

View 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>

View 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>