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.
+
+
+
#
Melhoria
Impacto
Valor
Horas
+
+
QW1
Limites 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 + alerta
crítico
95
3,0
+
QW2
Fix /v1/risk: path do risk_params.yaml resolvia fora do repo — GET devolvia defaults silenciosos e PATCH dava 500
crítico
90
0,5
+
QW3
Reset diário do circuit breaker — o contador "diário" nunca zerava; virava limite acumulado-desde-o-início
alto
85
1,5
+
QW4
Fix perda diária no /v1/risk/circuit-breaker — lia um campo de timestamp inexistente, então reportava sempre 0%
alto
70
0,5
+
QW5
Timeout nas chamadas LLM (LLM_TIMEOUT_SECONDS, 30s) — provider pendurado não trava mais o ciclo de trading
médio
60
1,0
+
QW6
Saldo paper passa a respeitar INITIAL_CAPITAL (antes: 10 000 hardcoded)
médio
50
0,5
+
QW7
Senhas changeme comentadas no .env.example — sem credenciais previsíveis num cp apressado
médio
40
0,2
+
QW8
Removido .env.template órfão (esquema conflitante com .env.example)
médio
35
0,2
+
QW9
Versão única em src/version.py — API reportava "1.0.0" falso
baixo
30
0,5
+
QW10
Removida dependência morta slowapi
baixo
25
0,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