Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Verified full Capital.com Open API coverage: a canonical endpoint registry
(`tests/e2e/endpoints.py`) plus CLI and SDK positive/negative e2e tests for
every endpoint, a generated coverage matrix in `docs/api-coverage.md`, and a
README **API coverage** badge backed by `docs/coverage-badge.json`.

### Fixed
- `trade execute-order`: working-order previews now persist the `type`/`level`
fields to the state file, so executing a previewed working order from a
separate CLI invocation no longer crashes with an internal `'type'` error.

## [0.5.0] - 2026-06-14

### Fixed
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install test lint fmt typecheck docs e2e check
.PHONY: install test lint fmt typecheck docs coverage-doc e2e check

install:
pip install -e ".[dev]"
Expand All @@ -18,6 +18,9 @@ typecheck:
docs:
typer capital_cli.cli.app utils docs --name capctl --output docs/CLI.md

coverage-doc: ## regenerate the API coverage matrix + badge from the registry
python tools/render_coverage.py

e2e:
CAPCTL_E2E=1 pytest tests/e2e -m e2e -v

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Browse markets, manage accounts and watchlists, preview and execute trades behin
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![Release](https://img.shields.io/github/v/release/SimonTarara62/capitalcom-cli?sort=semver)](https://github.com/SimonTarara62/capitalcom-cli/releases)
[![API coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/SimonTarara62/capitalcom-cli/main/docs/coverage-badge.json)](docs/api-coverage.md)

```text
$ capctl market search "gold"
Expand Down
4 changes: 4 additions & 0 deletions capital_cli/core/risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,10 @@ async def preview_working_order(self, request: PreviewWorkingOrderRequest) -> Pr
result.normalized_request["level"] = request.level
if request.good_till_date:
result.normalized_request["good_till_date"] = request.good_till_date
# preview_position already persisted this preview WITHOUT the working
# order fields; re-save so the CLI's separate execute-order process
# (which loads from the state file) sees type/level. (#working-order-exec)
self.state.save_preview(result)

return result

Expand Down
61 changes: 58 additions & 3 deletions docs/api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,61 @@ issues the request and surfaces the response (via tables or `--json`).
| `OHLCMarketData.subscribe` (candles) | `stream candles` | Covered |
| `ping` (keep-alive) | (internal) | Covered |

Not exposed as commands: the RSA encrypted-password login flow (the CLI uses the
standard credential login), and any endpoints Capital.com may add after this table
was written. Open an issue if something is missing.
**Verified coverage.** Every endpoint below is exercised by automated tests on the
real demo API across four cells — CLI positive, CLI negative, SDK positive, SDK
negative — generated from `tests/e2e/endpoints.py`. See the matrix at the bottom of
this file (regenerate with `make coverage-doc`). Three read-only connectivity
primitives (`GET /time`, `GET /session`, `GET /session/encryptionKey`) are CLI-only
by design and marked **N/A** in the SDK columns. The only Open API surface not
exposed as a command is the RSA encrypted-password login flow (the CLI uses the
standard credential login).

<!-- COVERAGE-MATRIX (generated by tools/render_coverage.py — do not edit below) -->

## Test matrix

| Endpoint | HTTP / WS | CLI + | CLI − | SDK + | SDK − |
|----------|-----------|:-----:|:-----:|:-----:|:-----:|
| `session.time` | `GET /time` | tested | tested | N/A | N/A |
| `session.ping` | `GET /ping` | tested | tested | tested | tested |
| `session.details` | `GET /session` | tested | tested | N/A | N/A |
| `session.encryption_key` | `GET /session/encryptionKey` | tested | tested | N/A | N/A |
| `session.login` | `POST /session` | tested | tested | tested | tested |
| `session.switch` | `PUT /session` | tested | tested | tested | tested |
| `session.logout` | `DELETE /session` | tested | tested | tested | tested |
| `account.list` | `GET /accounts` | tested | tested | tested | tested |
| `account.prefs_get` | `GET /accounts/preferences` | tested | tested | tested | tested |
| `account.prefs_set` | `PUT /accounts/preferences` | tested | tested | tested | tested |
| `account.history_activity` | `GET /history/activity` | tested | tested | tested | tested |
| `account.history_transactions` | `GET /history/transactions` | tested | tested | tested | tested |
| `account.topup` | `POST /accounts/topUp` | tested | tested | tested | tested |
| `market.search` | `GET /markets` | tested | tested | tested | tested |
| `market.get` | `GET /markets/{epic}` | tested | tested | tested | tested |
| `market.nav_root` | `GET /marketnavigation` | tested | tested | tested | tested |
| `market.nav_node` | `GET /marketnavigation/{id}` | tested | tested | tested | tested |
| `market.prices` | `GET /prices/{epic}` | tested | tested | tested | tested |
| `market.sentiment` | `GET /clientsentiment` | tested | tested | tested | tested |
| `position.list` | `GET /positions` | tested | tested | tested | tested |
| `position.get` | `GET /positions/{dealId}` | tested | tested | tested | tested |
| `position.preview` | `(local)` | tested | tested | tested | tested |
| `position.execute` | `POST /positions` | tested | tested | tested | tested |
| `position.amend` | `PUT /positions/{dealId}` | tested | tested | tested | tested |
| `position.close` | `DELETE /positions/{dealId}` | tested | tested | tested | tested |
| `order.list` | `GET /workingorders` | tested | tested | tested | tested |
| `order.preview` | `(local)` | tested | tested | tested | tested |
| `order.execute` | `POST /workingorders` | tested | tested | tested | tested |
| `order.amend` | `PUT /workingorders/{dealId}` | tested | tested | tested | tested |
| `order.cancel` | `DELETE /workingorders/{dealId}` | tested | tested | tested | tested |
| `trade.confirm` | `GET /confirms/{dealRef}` | tested | tested | tested | tested |
| `watchlist.list` | `GET /watchlists` | tested | tested | tested | tested |
| `watchlist.create` | `POST /watchlists` | tested | tested | tested | tested |
| `watchlist.get` | `GET /watchlists/{id}` | tested | tested | tested | tested |
| `watchlist.add` | `PUT /watchlists/{id}` | tested | tested | tested | tested |
| `watchlist.remove` | `DELETE /watchlists/{id}/{epic}` | tested | tested | tested | tested |
| `watchlist.delete` | `DELETE /watchlists/{id}` | tested | tested | tested | tested |
| `stream.prices` | `WS marketData.subscribe` | tested | tested | tested | tested |
| `stream.candles` | `WS OHLCMarketData.subscribe` | tested | tested | tested | tested |
| `stream.alerts` | `WS quotes (level cross)` | tested | tested | tested | tested |
| `stream.portfolio` | `WS quotes (position epics)` | tested | tested | tested | tested |

**Coverage: 158/158 applicable cells tested (100%) across 41 endpoints.** N/A = operation not exposed on that surface (CLI-only connectivity primitives).
6 changes: 6 additions & 0 deletions docs/coverage-badge.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"schemaVersion": 1,
"label": "API coverage",
"message": "158/158 (100%)",
"color": "brightgreen"
}
123 changes: 123 additions & 0 deletions tests/e2e/_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Shared e2e helpers: a non-asserting CLI runner (for negative exit-code checks
and env-override guard tests) and an SDK app builder that applies env overrides
with clean singleton isolation. Imported by the negative/positive e2e modules.

SECURITY: never reads or prints .env contents; only sets CAP_ENV_FILE to its path.
"""

from __future__ import annotations

import contextlib
import json
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]
ENV_FILE = REPO / ".env"
STATE_FILE = REPO / ".pytest_cache" / "e2e_state.json"

# Identifiers guaranteed not to exist on the demo account — used for safe 404s.
BAD_EPIC = "CAPCTL_NO_SUCH_EPIC"
BAD_DEAL_ID = "00000000-0000-0000-0000-000000000000"
BAD_WATCHLIST_ID = "99999999"
BAD_NODE_ID = "99999999"
BAD_DEAL_REF = "capctl_no_such_ref"


@dataclass
class CliResult:
code: int
stdout: str
stderr: str

def json(self) -> dict:
return json.loads(self.stdout) if self.stdout.strip() else {}

def error(self) -> dict:
"""Parse the structured error object the CLI writes to STDERR under --json.

Data goes to stdout; errors/notes go to stderr (see AGENTS.md). On error
stdout is empty, so negative tests read the error code from here. Log lines
may precede the JSON, so scan stderr lines from the end for the JSON object.
"""
for line in reversed(self.stderr.strip().splitlines()):
line = line.strip()
if line.startswith("{"):
try:
return json.loads(line)
except json.JSONDecodeError:
continue
return {}

def error_code(self) -> str | None:
return self.error().get("error", {}).get("code")


def run_cli(*args: str, env_overrides: dict[str, str] | None = None) -> CliResult:
"""Run `capctl --json <args>` and RETURN the result (never asserts the code).

env_overrides lets a negative test force a guard (e.g. CAP_ALLOW_TRADING=false)
without touching the account. Paces 1.2s to respect the 1 req/s login limit.
"""
import os

env = dict(os.environ, CAP_ENV_FILE=str(ENV_FILE), CAPCTL_STATE_FILE=str(STATE_FILE))
if env_overrides:
env.update(env_overrides)
proc = subprocess.run(
[sys.executable, "-m", "capital_cli", "--json", *args],
capture_output=True,
text=True,
timeout=180,
env=env,
cwd=REPO,
)
time.sleep(1.2)
return CliResult(proc.returncode, proc.stdout, proc.stderr)


def _reset_all_singletons() -> None:
from capital_cli.core.config import reset_config
from capital_cli.core.http_client import reset_client
from capital_cli.core.rate_limit import reset_rate_limiter
from capital_cli.core.risk import reset_risk_engine
from capital_cli.core.session import reset_session_manager
from capital_cli.core.state import reset_state_store

reset_config()
reset_client()
reset_session_manager()
reset_risk_engine()
reset_rate_limiter()
reset_state_store()


@contextlib.contextmanager
def sdk_app(env_overrides: dict[str, str] | None = None):
"""Yield a fresh CapitalComApp built from the real .env plus optional overrides.

Overrides (e.g. CAP_ALLOW_TRADING=false, CAP_REQUIRE_EXPLICIT_CONFIRM=true) are
applied to os.environ, singletons are rebuilt so services read them, and both
are restored on exit. Use for SDK negative guard tests in-process.
"""
import os

os.environ["CAP_ENV_FILE"] = str(ENV_FILE)
saved = {k: os.environ.get(k) for k in (env_overrides or {})}
if env_overrides:
os.environ.update(env_overrides)
_reset_all_singletons()
try:
from capital_cli.sdk import CapitalComApp

yield CapitalComApp()
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
_reset_all_singletons()
Loading
Loading