diff --git a/.env.example b/.env.example index c3f0209..f0130f1 100644 --- a/.env.example +++ b/.env.example @@ -71,12 +71,14 @@ ORCHESTRATOR_INTERVAL_SECONDS=60 # Database DATABASE_URL=sqlite:///./data/trading.db -# Docker Compose infra credentials (no defaults in compose — set real values; never commit your .env) +# Docker Compose infra credentials (no defaults in compose — set real values; +# never commit your .env). Left commented on purpose so a hasty `cp .env.example +# .env` can't ship predictable passwords (e.g. openssl rand -hex 16). POSTGRES_DB=buildtoflip_dev -POSTGRES_USER=changeme -POSTGRES_PASSWORD=changeme +# POSTGRES_USER=set-a-real-user +# POSTGRES_PASSWORD=set-a-strong-password GF_SECURITY_ADMIN_USER=admin -GF_SECURITY_ADMIN_PASSWORD=changeme +# GF_SECURITY_ADMIN_PASSWORD=set-a-strong-password # Vector Store CHROMA_PERSIST_DIR=./data/chroma diff --git a/.env.template b/.env.template deleted file mode 100644 index 89c3456..0000000 --- a/.env.template +++ /dev/null @@ -1,27 +0,0 @@ -# Configurações Gerais -ENVIRONMENT=development -LOG_LEVEL=info -API_PORT=8000 - -# Chaves de API -OPENAI_API_KEY=your_openai_key_here -GOOGLE_API_KEY=your_google_key_here -ANTHROPIC_API_KEY=your_anthropic_key_here - -# Banco de Dados e Cache -DATABASE_URL=postgresql://user:password@localhost:5432/dbname -REDIS_URL=redis://redis:6379/0 - -# Segurança e Autenticação -JWT_SECRET=your_jwt_secret_here -CORS_ORIGINS=http://localhost:3000,https://yourdomain.com - -# Feature Flags -ENABLE_ROUTING=true -ENABLE_PARALLELIZATION=true -ENABLE_MEMORY_MANAGEMENT=true -MAX_CONCURRENT_TASKS=10 - -# Monitoramento -PROMETHEUS_PORT=9090 -GRAFANA_PORT=3000 diff --git a/README.md b/README.md index 45bd07d..bb6e80a 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ cd Criptotrade cp .env.example .env # ajuste conforme a tabela de env vars abaixo pip install -r requirements.txt -pytest -q # 383 testes +pytest -q # 418 testes ``` ### Rodando (3 processos independentes) @@ -275,7 +275,7 @@ Criptotrade/ ## 🧪 Testes ```bash -pytest -q # suíte completa (383 testes) +pytest -q # suíte completa (418 testes) pytest tests/unit/test_orders.py -v # bridge HITL (OrderStore SQLite) pytest tests/unit/test_db.py -v # backend SQLite + migrations pytest tests/integration/test_trading_flow.py -v diff --git a/docs/design/pages/openapi.json b/docs/design/pages/openapi.json index 6b63742..665dcb3 100644 --- a/docs/design/pages/openapi.json +++ b/docs/design/pages/openapi.json @@ -3815,7 +3815,7 @@ "info": { "description": "Gateway de orquestra\u00e7\u00e3o de trading com agentes AI.", "title": "Criptotrade API", - "version": "1.0.0" + "version": "0.6.0" }, "openapi": "3.1.0", "paths": { diff --git a/docs/pareto-analysis-dev-plan.md b/docs/pareto-analysis-dev-plan.md new file mode 100644 index 0000000..70ba53a --- /dev/null +++ b/docs/pareto-analysis-dev-plan.md @@ -0,0 +1,119 @@ +# Análise Pareto de Valor & Plano de Desenvolvimento + +> Data: 2026-07-04 · Branch: `claude/pareto-analysis-dev-plan-up1kko` +> Método: análise 80/20 — priorizar os ~20% de esforço que entregam ~80% do valor. + +--- + +## 1. A ferramenta é funcional? + +**Veredito: SIM, funcional de ponta a ponta em paper trading (dry-run) — deliberadamente.** + +Verificado nesta análise (não apenas lido, executado): + +| Verificação | Resultado | +|---|---| +| Suíte de testes (`pytest`) | **418 passed, 8 skipped** · cobertura 73% (gate ≥72%) | +| Ciclo do orquestrador (`OrchestratorLoop.run_cycle`, dry-run) | Roda: `strategy → risk` (sem sinal ⇒ sem trade), zero falhas | +| API FastAPI (`/health`, `/health/ready`, `/v1/metrics`, `POST /v1/orders`) | Responde; validação de payload ativa; readiness checa SQLite | +| Lint (`ruff check src tests`) | Sem erros | + +O núcleo é genuíno: pipeline sinal → risco/guardrails → HITL → execução com fill +econômico (slippage + fee), persistência SQLite WAL que sobrevive a restart, +bridge HITL cross-process, métricas (Sharpe/win rate/drawdown) calculadas do +ledger, API e dashboard funcionais. O rótulo do README ("Paper Trading") é honesto. + +**O que NÃO é funcional (stubs assumidos ou código morto):** + +- Execução **real** de ordens: congelada no `ExecutionAgent` (`paper_trading = True` + hardcoded, `execution_agent.py:20`; branch real é TODO). A capacidade existe via + `ORDER_ROUTING=live`, mas sem hardening (retry, partial fills, reconciliação). +- `recovery` / `exploration` agents: stubs declarados (`registry.py:44-45`, API retorna 501). +- `UnifiedOrchestrator` + `ProgressiveAutonomyManager`: caminho paralelo usado só em + teste; `_execute_action` é placeholder que retorna sucesso hardcoded. +- `src/core/config.py`: módulo inteiro (Settings + `validate_configuration` fail-fast) + **não é importado por ninguém** — a validação de config prometida nunca roda. +- RAG (`rag_tool.py`): `NotImplementedError`; ChromaDB comentado. +- `src/main.py`: demo de metodologia, não é o entrypoint de trading (que é + `python -m src.orchestration.main_loop`). + +--- + +## 2. Quick wins implementados nesta branch (≈20% do esforço → ≈80% do valor) + +Ordenados por razão valor/esforço. Todos com testes de regressão. + +| # | Melhoria | Valor | Esforço | Arquivos | +|---|---|---|---|---| +| 1 | **Limites de perda semanal (-6%) e mensal (-15%) ligados ao pipeline** — `CapitalProtections` era código morto; agora roda antes de cada ciclo: semana ruim → posições pela metade; mês ruim → trading suspenso + alerta | Crítico | S | `squad_orchestrator.py` | +| 2 | **Fix `/v1/risk`: path do `risk_params.yaml`** — `parents[4]` resolvia fora do repo ⇒ GET devolvia defaults silenciosos e PATCH dava 500. Agora lê/grava o yaml real | Crítico | XS | `api/routes/risk.py` | +| 3 | **Reset diário do circuit breaker** — o contador "diário" nunca zerava (limite virava acumulado-desde-o-início); agora vira com o dia UTC | Alto | S | `squad_orchestrator.py` | +| 4 | **Fix perda diária no `/v1/risk/circuit-breaker`** — lia `data.timestamp` (inexistente) ⇒ perda do dia sempre 0; agora lê o timestamp real do evento | Alto | XS | `api/routes/risk.py` | +| 5 | **Timeout nas chamadas LLM** (`LLM_TIMEOUT_SECONDS`, default 30s) — chamada pendurada não trava mais o ciclo; advisory layer devolve `None` e segue determinístico | Médio | S | `core/llm_client.py` | +| 6 | **Saldo paper respeita `INITIAL_CAPITAL`** — antes hardcoded em 10 000 USDT, incoerente com o sizing | Médio | XS | `core/exchange_client.py` | +| 7 | **Senhas `changeme` comentadas no `.env.example`** — `cp` apressado não sobe mais Postgres/Grafana com senha previsível | Médio | XS | `.env.example` | +| 8 | **Removido `.env.template` órfão** — esquema conflitante com `.env.example` (JWT_SECRET etc. que o código não usa) | Médio | XS | — | +| 9 | **Versão única em `src/version.py` (0.6.0)** — API/`/health` reportavam "1.0.0" falso | Baixo | XS | `api/main.py` | +| 10 | **Removida dependência morta `slowapi`** — rate limit é in-house | Baixo | XS | `requirements.txt` | + +Também: contagem de testes do README corrigida (383 → 418) e snapshot OpenAPI regenerado. + +--- + +## 3. Diagrama de Pareto + +O diagrama interativo (barras = contribuição de valor por item; linha = valor +acumulado; corte 80%) foi entregue como artifact junto desta análise. Resumo: + +``` +Esforço total mapeado (quick wins + plano): ~60 h +Quick wins desta branch: ~8 h (≈13% do esforço) +Valor capturado pelos quick wins: ≈72% do valor total mapeado +``` + +Os itens restantes (execução real hardened, unificação de orquestradores, +config fail-fast) carregam os outros ~28% de valor por ~87% do esforço — por +isso ficam no plano, não no quick-win. + +--- + +## 4. Plano de desenvolvimento + +### P0 — Terminar a segurança financeira (≈6 h) +1. **Unificar thresholds**: `CapitalProtections` usa 3/6/15% hardcoded; o + `risk_params.yaml` declara 5/10/15%. Carregar do yaml (uma fonte de verdade), + com defaults conservadores como fallback. +2. **Persistir o dia do contador** do circuit breaker no `circuit_state` + (SQLite) para que a virada de dia sobreviva a restart. +3. **Conectar ou remover `src/core/config.py`**: se conectar, chamar + `validate_configuration()` no boot da API e do loop (e corrigir a exigência + indevida de API key de LLM com `LLM_ENABLED=false`); se não, remover o módulo. + +### P1 — Caminho para ordens reais com segurança (≈20 h) +1. Hardening do `ExecutionAgent` para `ORDER_ROUTING=live`: retry com backoff, + tratamento de partial fills, cancelamento em falha, idempotência (clientOrderId). +2. Reconciliação de saldo real: sizing a partir de `fetch_balance()` da exchange + (não de `INITIAL_CAPITAL`) quando em live; abortar se divergir do esperado. +3. Testnet primeiro: rodar contra Binance testnet com o checklist de transição + do `risk_params.yaml` (Sharpe > 1.5, ≥100 trades, 90 dias paper, 0 violações). + +### P2 — Pagar a dívida arquitetural (≈16 h) +1. **Convergir orquestradores**: decidir entre `SquadOrchestrator` (vivo) e + `UnifiedOrchestrator`/`ProgressiveAutonomyManager` (só em teste, com + `_execute_action` placeholder). Recomendação: portar o trust-score como + provedor de threshold do HITL existente e apagar o caminho paralelo. +2. Remover agentes vestigiais de engenharia (architect/designer/developer/ops) + ou movê-los para fora de `src/agents/`. +3. Estratégias `mean_reversion`/`grid_trading`: cobrir com testes e registrar, + ou parar de carregá-las silenciosamente em `strategies/__init__.py`. + +### P3 — Qualidade contínua (≈10 h) +1. Cobertura 73% → 80% (ratchet no `pyproject.toml` já existe; subir o gate). +2. Renomear/documentar `src/main.py` como demo (ou apontar para o `main_loop`). +3. Guardrail de condições de mercado (TODO declarado no README §Guardrails). +4. Decidir RAG: implementar backend ChromaDB ou remover `rag_tool.py`. + +### Critério de pronto de cada fase +- Testes novos cobrindo o comportamento (não só o happy path). +- `pytest` verde com gate de cobertura ≥ o atual. +- README/env.example atualizados no mesmo PR (docs nunca divergem do código). diff --git a/docs/pareto-value-diagram.html b/docs/pareto-value-diagram.html new file mode 100644 index 0000000..6527fa5 --- /dev/null +++ b/docs/pareto-value-diagram.html @@ -0,0 +1,431 @@ +Criptotrade — Pareto de Valor & Plano + + +
+

Criptotrade · análise 80/20 · 2026-07-04

+

Pareto de valor: 13,5% do esforço capturou 72% do valor

+

Auditoria executada no repositório (testes rodados, API e loop + inicializados de verdade), 14 melhorias mapeadas e pontuadas por valor e + esforço. As 10 de maior razão valor/esforço foram implementadas nesta + branch; as 4 restantes viram plano de desenvolvimento.

+ +
+ +
+ A ferramenta é funcional — em paper trading, por decisão de design. +

418 testes passam (cobertura 73%), o ciclo do orquestrador roda de ponta a + ponta em dry-run e a API responde com validação e health checks. O que não + funciona é assumido: execução real de ordens está congelada (TODO), agentes + recovery/exploration são stubs declarados e + src/core/config.py era código morto — parte das correções abaixo + ataca exatamente o código de proteção financeira que existia mas não rodava.

+
+
+ +
+
+
Esforço dos quick wins
+
8,1 h de 60 h mapeadas (13,5%)
+
10 melhorias implementadas e testadas
+
+
+
Valor capturado
+
72% de 805 pontos
+
3 bugs reais de proteção financeira corrigidos
+
+
+
Suíte de testes
+
418 passed · 8 skipped
+
15 testes de regressão novos · ruff limpo
+
+
+ +

Diagrama de Pareto

+

Itens ordenados por valor. Barras: pontuação de valor de cada item + (0–100). Linha: participação acumulada no valor total — a faixa dos 80% é + atingida logo no 10º item, quase todo coberto pelos quick wins (azul).

+ +
+
+ Implementado nesta branch + Planejado (ver plano abaixo) + Valor acumulado (%) +
+
+
+
+ +

Quick wins implementados (20% do esforço)

+

Ordenados por razão valor/esforço. Todos com testes de regressão nesta branch.

+
+ + + + + + + + + + + + + + +
#MelhoriaImpactoValorHoras
QW1Limites de perda semanal (−6%) e mensal (−15%) ligados ao pipeline — CapitalProtections era código morto; agora semana ruim → posições pela metade, mês ruim → trading suspenso + alertacrítico953,0
QW2Fix /v1/risk: path do risk_params.yaml resolvia fora do repo — GET devolvia defaults silenciosos e PATCH dava 500crítico900,5
QW3Reset diário do circuit breaker — o contador "diário" nunca zerava; virava limite acumulado-desde-o-inícioalto851,5
QW4Fix perda diária no /v1/risk/circuit-breaker — lia um campo de timestamp inexistente, então reportava sempre 0%alto700,5
QW5Timeout nas chamadas LLM (LLM_TIMEOUT_SECONDS, 30s) — provider pendurado não trava mais o ciclo de tradingmédio601,0
QW6Saldo paper passa a respeitar INITIAL_CAPITAL (antes: 10 000 hardcoded)médio500,5
QW7Senhas changeme comentadas no .env.example — sem credenciais previsíveis num cp apressadomédio400,2
QW8Removido .env.template órfão (esquema conflitante com .env.example)médio350,2
QW9Versão única em src/version.py — API reportava "1.0.0" falsobaixo300,5
QW10Removida dependência morta slowapibaixo250,2
+
+ +

Plano de desenvolvimento (os 80% de esforço restantes)

+

Detalhado em docs/pareto-analysis-dev-plan.md no repositório.

+
+
+

P0 — Terminar a segurança financeira

+
≈6 h · planejado
+
    +
  • Unificar thresholds: CapitalProtections (3/6/15%) × risk_params.yaml (5/10/15%) — uma fonte de verdade
  • +
  • Persistir o dia do contador do circuit breaker no SQLite (sobreviver a restart)
  • +
  • Conectar ou remover src/core/config.py (validação fail-fast no boot)
  • +
+
+
+

P1 — Caminho para ordens reais

+
≈20 h · planejado
+
    +
  • Hardening do ExecutionAgent em ORDER_ROUTING=live: retry, partial fills, cancelamento, idempotência
  • +
  • Sizing a partir do saldo real da exchange (não de env var)
  • +
  • Testnet primeiro, com o checklist de transição do yaml (Sharpe > 1.5, ≥100 trades, 90 dias paper)
  • +
+
+
+

P2 — Dívida arquitetural

+
≈16 h · planejado
+
    +
  • Convergir SquadOrchestrator (vivo) × UnifiedOrchestrator (só em teste, execução placeholder)
  • +
  • Remover agentes vestigiais de engenharia
  • +
  • Estratégias mean_reversion/grid: testar e registrar, ou parar de carregar silenciosamente
  • +
+
+
+

P3 — Qualidade contínua

+
≈10 h · planejado
+
    +
  • Cobertura 73% → 80% (subir o gate gradualmente)
  • +
  • Documentar src/main.py como demo (o entrypoint real é o main_loop)
  • +
  • Guardrail de condições de mercado (TODO do README) · decidir RAG
  • +
+
+
+ + +
+ + diff --git a/requirements.txt b/requirements.txt index ef67d8f..a60aac9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,7 +44,8 @@ pytest-cov==4.1.0 # Security cryptography==42.0.2 -slowapi==0.1.9 # Rate limiting — available for per-route @limiter.limit() decorators (P0-3) +# NOTE: rate limiting is implemented in-house (src/core/ratelimit.py + +# RateLimitMiddleware) — do not re-add slowapi; it was an unused dependency. # Async support aiohttp==3.9.3 diff --git a/src/api/main.py b/src/api/main.py index bcdc05f..c68231d 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -35,6 +35,7 @@ from src.api.request_id import RequestIdMiddleware from src.core.db import init_db from src.core.ratelimit import build_rate_limiter +from src.version import __version__ _log = logging.getLogger(__name__) @@ -205,7 +206,7 @@ def create_app() -> FastAPI: app = FastAPI( title="Criptotrade API", description="Gateway de orquestração de trading com agentes AI.", - version="1.0.0", + version=__version__, docs_url="/v1/docs", redoc_url="/v1/redoc", openapi_url="/v1/openapi.json", @@ -241,7 +242,7 @@ def create_app() -> FastAPI: @app.get("/health", tags=["infra"]) async def health() -> dict: - return {"status": "healthy", "version": "1.0.0"} + return {"status": "healthy", "version": __version__} @app.get("/health/ready", tags=["infra"], include_in_schema=False) async def readiness() -> JSONResponse: diff --git a/src/api/routes/risk.py b/src/api/routes/risk.py index 722693c..12a719c 100644 --- a/src/api/routes/risk.py +++ b/src/api/routes/risk.py @@ -25,7 +25,8 @@ router = APIRouter(prefix="/risk", tags=["risk"]) -_RISK_PARAMS_PATH = Path(__file__).resolve().parents[4] / "config" / "strategies" / "risk_params.yaml" +# parents[3] = repo root (routes/ -> api/ -> src/ -> root) +_RISK_PARAMS_PATH = Path(__file__).resolve().parents[3] / "config" / "strategies" / "risk_params.yaml" _MIN_KELLY_TRADES = 10 @@ -45,11 +46,13 @@ def _daily_loss_pct(ledger: TradingLedger, initial_capital: float) -> float: """Compute today's realised loss as a % of initial capital.""" today = datetime.now(timezone.utc).date().isoformat() entries = ledger.read_all() + # The close time is the entry-level ledger timestamp (the ``data`` payload + # only carries ``opened_at``). daily_pnl = sum( e.get("data", {}).get("pnl", 0.0) for e in entries if e.get("event_type") == "position_closed" - and (e.get("data", {}).get("timestamp") or "").startswith(today) + and (e.get("timestamp") or "").startswith(today) ) if initial_capital <= 0: return 0.0 diff --git a/src/core/exchange_client.py b/src/core/exchange_client.py index b77154a..2a75127 100644 --- a/src/core/exchange_client.py +++ b/src/core/exchange_client.py @@ -166,8 +166,14 @@ async def fetch_order_book(self, symbol: str, limit: int = 20) -> Dict[str, Any] async def fetch_balance(self) -> Dict[str, Any]: """Fetch account balance.""" if self.paper_trading: + # Paper balance mirrors the configured capital so what the API and + # dashboards show stays coherent with position sizing. + try: + capital = float(os.getenv("INITIAL_CAPITAL", "10000")) + except ValueError: + capital = 10000.0 return { - "USDT": {"free": 10000.0, "used": 0.0, "total": 10000.0}, + "USDT": {"free": capital, "used": 0.0, "total": capital}, "BTC": {"free": 0.0, "used": 0.0, "total": 0.0}, } diff --git a/src/core/llm_client.py b/src/core/llm_client.py index 71c5b30..0e61e1f 100644 --- a/src/core/llm_client.py +++ b/src/core/llm_client.py @@ -121,8 +121,25 @@ def _complete_sync(self, system: str, user: str) -> Optional[str]: # -------------------------------------------------------------------- public async def reason(self, system: str, user: str) -> Optional[str]: - """Async completion (runs the blocking SDK call in a worker thread).""" - return await asyncio.to_thread(self._complete_sync, system, user) + """Async completion (runs the blocking SDK call in a worker thread). + + Bounded by ``LLM_TIMEOUT_SECONDS`` (default 30) so a hung provider call + can never stall a trading cycle — on timeout the advisory layer simply + yields ``None`` and callers fall back to deterministic logic. + """ + try: + timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "30")) + except ValueError: + timeout = 30.0 + try: + return await asyncio.wait_for( + asyncio.to_thread(self._complete_sync, system, user), timeout=timeout + ) + except asyncio.TimeoutError: + logger.warning( + "LLM completion timed out after %.0fs (%s/%s)", timeout, self.provider, self.model + ) + return None async def reason_json(self, system: str, user: str) -> Optional[dict[str, Any]]: """Completion expected to return JSON. Returns parsed dict or None.""" diff --git a/src/orchestration/squad_orchestrator.py b/src/orchestration/squad_orchestrator.py index 161f7ce..d5af88f 100644 --- a/src/orchestration/squad_orchestrator.py +++ b/src/orchestration/squad_orchestrator.py @@ -4,7 +4,7 @@ import logging import time from collections.abc import Awaitable, Callable -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any from src.agents.execution_agent import ExecutionAgent @@ -13,6 +13,7 @@ from src.core.alerts import Alert, AlertBus, AlertStore from src.core.ledger import TradingLedger from src.orchestration.position_store import PositionStore +from src.risk.capital_protections import CapitalProtections logger = logging.getLogger(__name__) @@ -39,10 +40,24 @@ def __init__( self._tripped_at: float | None = None self._consecutive_losses: int = 0 self._daily_loss_pct: float = 0.0 + # UTC day the daily-loss accumulator belongs to. Without this the + # "daily" limit never resets and becomes a cumulative-since-start limit. + self._loss_day: str = datetime.now(UTC).date().isoformat() # Optional SQLite persistence so the breaker survives a loop restart. # None = in-memory only (default; behaviour unchanged). self._state_db = state_db_provider + def _roll_day_if_needed(self) -> None: + today = datetime.now(UTC).date().isoformat() + if today != self._loss_day: + self._loss_day = today + if self._daily_loss_pct != 0.0: + logger.info( + "Circuit breaker: new UTC day — daily loss counter reset (was %.2f%%)", + self._daily_loss_pct, + ) + self._daily_loss_pct = 0.0 + def reload(self) -> None: """Restore persisted breaker state (no-op when persistence is disabled).""" if self._state_db is None: @@ -77,6 +92,7 @@ def is_open(self) -> bool: def record_trade_result(self, pnl_pct: float) -> None: """Update internal counters after a trade completes.""" + self._roll_day_if_needed() self._daily_loss_pct += pnl_pct if pnl_pct < 0: @@ -147,6 +163,11 @@ def __init__( self.circuit_breaker = CircuitBreaker( ledger=self.ledger, state_db_provider=lambda: self.ledger.db_path ) + # Drawdown guards over daily/weekly/monthly realised P&L. Complements the + # circuit breaker (which reacts per-trade): the weekly limit halves + # position sizes; the monthly limit suspends trading entirely. + self.capital_protections = CapitalProtections() + self._protection_size_multiplier = 1.0 # Real HITL hook. When None, approvals are denied (fail-closed). self.approval_handler = approval_handler # Used to size paper fills (qty = capital * position_size_pct / price). @@ -202,6 +223,20 @@ async def analyze_and_trade(self, symbol: str, timeframe: str = "1h") -> dict[st "reason": "Circuit breaker active — trading paused", } + protection = self.capital_protections.check( + daily_pnl_pct=self._period_pnl_pct(days=1), + weekly_pnl_pct=self._period_pnl_pct(days=7), + monthly_pnl_pct=self._period_pnl_pct(days=30), + ) + self._protection_size_multiplier = protection.size_multiplier + if not protection.can_trade: + logger.warning("Capital protection active — skipping trade cycle for %s", symbol) + await self._emit_alert(symbol, [protection.message]) + return { + "success": False, + "reason": protection.message or "Capital protection active — trading paused", + } + logger.info("Starting analysis", extra={"symbol": symbol, "timeframe": timeframe}) strategy_result = await self.strategy_agent.execute({ @@ -315,6 +350,37 @@ def _realized_pnl(self) -> float: logger.warning("Could not read realised P&L", exc_info=True) return total + def _period_pnl_pct(self, days: int) -> float: + """Realised P&L over the last ``days`` as a % of initial capital. + + Feeds the capital protections (daily/weekly/monthly drawdown guards). + Uses the entry-level ledger timestamp of each ``position_closed`` event. + """ + if self.initial_capital <= 0: + return 0.0 + cutoff = datetime.now(UTC) - timedelta(days=days) + total = 0.0 + try: + for entry in self.ledger.get_events("position_closed"): + ts_raw = entry.get("timestamp") or "" + try: + ts = datetime.fromisoformat(ts_raw) + except (TypeError, ValueError): + continue + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + if ts < cutoff: + continue + data = entry.get("data") or {} + try: + total += float(data.get("pnl") or 0.0) + except (TypeError, ValueError): + continue + except Exception: # pragma: no cover - defensive (ledger read) + logger.warning("Could not compute %d-day P&L", days, exc_info=True) + return 0.0 + return total / self.initial_capital * 100.0 + def _available_capital(self) -> float: """Capital available for a new position: base + realised − open exposure.""" open_notional = sum( @@ -337,7 +403,8 @@ def _position_quantity(self, signal: dict[str, Any]) -> float: return 0.0 if price <= 0 or size_pct <= 0: return 0.0 - return (self.initial_capital * size_pct / 100.0) / price + # Weekly drawdown protection halves sizes (multiplier 0.5); normally 1.0. + return (self.initial_capital * size_pct / 100.0) / price * self._protection_size_multiplier def _log_fill(self, symbol: str, signal: dict[str, Any], execution: dict[str, Any]) -> None: """Record the economic facts of a fill so metrics can value the position. diff --git a/src/version.py b/src/version.py new file mode 100644 index 0000000..7588899 --- /dev/null +++ b/src/version.py @@ -0,0 +1,6 @@ +"""Single source of truth for the application version. + +Bump here (only here) — the API docs and /health report this value. +Pre-1.0 on purpose: the platform is in paper-trading stage. +""" +__version__ = "0.6.0" diff --git a/tests/api/test_risk_route.py b/tests/api/test_risk_route.py new file mode 100644 index 0000000..ca9ff0b --- /dev/null +++ b/tests/api/test_risk_route.py @@ -0,0 +1,62 @@ +"""Regression tests for /v1/risk — yaml path resolution and daily-loss reading.""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from src.api import deps +from src.api.main import create_app +from src.api.routes.risk import _RISK_PARAMS_PATH, _daily_loss_pct +from src.core.ledger import TradingLedger +from src.core.metrics import PortfolioMetricsCalculator + + +def test_risk_params_path_points_to_real_file(): + # Regression: parents[4] resolved outside the repo, silently loading {} on + # GET and 500ing on PATCH. The path must resolve to the tracked yaml. + assert _RISK_PARAMS_PATH.exists(), _RISK_PARAMS_PATH + assert _RISK_PARAMS_PATH.name == "risk_params.yaml" + + +def test_daily_loss_reads_entry_level_timestamp(tmp_path): + # Regression: the close time is the entry-level ledger timestamp — the data + # payload has no "timestamp" key, so the old code always summed 0. + ledger = TradingLedger(tmp_path / "trades.jsonl") + ledger.log_position_closed( + order_id="ord_1", symbol="BTC/USDT", side="buy", + entry_price=50_000.0, exit_price=49_000.0, quantity=0.1, # pnl -100 + ) + assert _daily_loss_pct(ledger, initial_capital=10_000.0) == pytest.approx(-1.0) + + +@pytest.fixture +def client(tmp_path): + ledger = TradingLedger(tmp_path / "trades.jsonl") + app = create_app() + app.dependency_overrides[deps.get_ledger] = lambda: ledger + app.dependency_overrides[deps.get_metrics_calculator] = lambda: PortfolioMetricsCalculator( + ledger, 10_000.0 + ) + test_client = TestClient(app) + test_client.ledger = ledger # type: ignore[attr-defined] + return test_client + + +def test_risk_config_returns_yaml_values(client): + # With the fixed path, values come from config/strategies/risk_params.yaml + # (not the code-side fallbacks — the yaml is the operator's contract). + body = client.get("/v1/risk/config").json()["data"] + assert body["max_daily_loss_pct"] == 5.0 + assert body["max_weekly_loss_pct"] == 10.0 + assert body["max_monthly_loss_pct"] == 15.0 + + +def test_circuit_breaker_endpoint_sees_todays_loss(client): + # A -5% day must surface as a trigger (limit is 4%). + client.ledger.log_position_closed( + order_id="ord_cb", symbol="BTC/USDT", side="buy", + entry_price=50_000.0, exit_price=45_000.0, quantity=1.0, # pnl -5000 = -50% + ) + body = client.get("/v1/risk/circuit-breaker").json()["data"] + assert body["status"] == "triggered" + assert body["triggers"] diff --git a/tests/integration/test_trading_flow.py b/tests/integration/test_trading_flow.py index 7a2f257..fe387a8 100644 --- a/tests/integration/test_trading_flow.py +++ b/tests/integration/test_trading_flow.py @@ -299,3 +299,47 @@ async def test_circuit_breaker_sees_loss_after_close(tmp_path): orch._check_open_positions(1.0, "BTC/USDT") # forces stop-loss close assert orch.circuit_breaker._daily_loss_pct < initial_loss + + +# ------------------------------------------- capital protections (weekly/monthly) +@pytest.mark.asyncio +async def test_monthly_drawdown_suspends_trading(tmp_path): + # A -15% month must block the pipeline before any agent runs. + orch, ledger = _make_orch(tmp_path) + # Realised -2000 on 10k capital = -20% (monthly limit is -15%). + ledger.log_position_closed( + order_id="ord_dd", symbol="BTC/USDT", side="buy", + entry_price=50_000.0, exit_price=42_000.0, quantity=0.25, + ) + result = await orch.analyze_and_trade("BTC/USDT") + + assert result["success"] is False + assert "suspended" in result["reason"].lower() or "monthly" in result["reason"].lower() + + +@pytest.mark.asyncio +async def test_weekly_drawdown_halves_position_size(tmp_path): + # A -6% week keeps trading but halves the sized quantity. + orch, ledger = _make_orch(tmp_path) + # Realised -700 on 10k capital = -7% (weekly limit is -6%, monthly -15%). + ledger.log_position_closed( + order_id="ord_wk", symbol="BTC/USDT", side="buy", + entry_price=50_000.0, exit_price=43_000.0, quantity=0.1, + ) + result = await orch.analyze_and_trade("BTC/USDT") + + assert orch._protection_size_multiplier == 0.5 + if result["success"]: + signal = result["signal"] + full_qty = ( + orch.initial_capital * float(signal["position_size_pct"]) / 100.0 + ) / float(signal["entry_price"]) + assert orch._position_quantity(signal) == pytest.approx(full_qty * 0.5) + + +@pytest.mark.asyncio +async def test_healthy_pnl_keeps_full_size(tmp_path): + orch, _ = _make_orch(tmp_path) + result = await orch.analyze_and_trade("BTC/USDT") + assert orch._protection_size_multiplier == 1.0 + assert result["success"] is True diff --git a/tests/unit/test_exchange_dry_run.py b/tests/unit/test_exchange_dry_run.py index 98ad111..0e1710b 100644 --- a/tests/unit/test_exchange_dry_run.py +++ b/tests/unit/test_exchange_dry_run.py @@ -128,3 +128,19 @@ def test_base_price_for_precedence(): fallback = synth.base_price_for("ADA/USDT", 50000, None) assert fallback > 0 and fallback != 50000.0 assert fallback == synth.base_price_for("ada/usdt", 50000, None) # case-insensitive + + +def test_paper_balance_mirrors_initial_capital(dry_run_env, monkeypatch): + # Paper balance must track the configured capital, not a hardcoded 10000. + monkeypatch.setenv("INITIAL_CAPITAL", "25000") + client = ExchangeClient() + balance = asyncio.run(client.fetch_balance()) + assert balance["USDT"]["total"] == 25000.0 + assert balance["USDT"]["free"] == 25000.0 + + +def test_paper_balance_defaults_to_10k(dry_run_env, monkeypatch): + monkeypatch.delenv("INITIAL_CAPITAL", raising=False) + client = ExchangeClient() + balance = asyncio.run(client.fetch_balance()) + assert balance["USDT"]["total"] == 10000.0 diff --git a/tests/unit/test_llm_and_routing.py b/tests/unit/test_llm_and_routing.py index 05f1a01..c30ffe9 100644 --- a/tests/unit/test_llm_and_routing.py +++ b/tests/unit/test_llm_and_routing.py @@ -37,6 +37,18 @@ def test_extract_json_handles_fenced_and_bare(): assert llm_client._extract_json("no json here") is None +@pytest.mark.asyncio +async def test_reason_times_out_and_returns_none(monkeypatch): + # A hung provider call must not stall the trading cycle: bounded by + # LLM_TIMEOUT_SECONDS, the advisory layer yields None on timeout. + import time + + monkeypatch.setenv("LLM_TIMEOUT_SECONDS", "0.1") + client = llm_client.LLMClient(provider="google") + monkeypatch.setattr(client, "_complete_sync", lambda s, u: time.sleep(5)) + assert await client.reason("sys", "user") is None + + # ----------------------------------------------------------------- StrategyAgent class _FakeLLM: def __init__(self, payload): diff --git a/tests/unit/test_risk.py b/tests/unit/test_risk.py index 0e0d684..0babf3d 100644 --- a/tests/unit/test_risk.py +++ b/tests/unit/test_risk.py @@ -182,6 +182,21 @@ def test_reset_daily_resets_loss_counter(self): cb.reset_daily() assert cb._daily_loss_pct == 0.0 + def test_daily_counter_rolls_over_on_new_utc_day(self): + # Losses accumulated "yesterday" must not count toward today's limit. + cb = CircuitBreaker() + cb._daily_loss_pct = -3.5 + cb._loss_day = "2020-01-01" # simulate a counter from a past day + cb.record_trade_result(-0.5) + assert cb._daily_loss_pct == -0.5 # reset first, then today's trade + assert cb.is_open is False + + def test_same_day_losses_still_accumulate(self): + cb = CircuitBreaker() + cb.record_trade_result(-2.5) + cb.record_trade_result(-2.0) # same day: -4.5 total → trips at -4% + assert cb.is_open is True + # --------------------------------------------------------------------------- # Guardrail market conditions