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
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ Guidance for AI coding agents working in this repository. Humans: start with

`capctl` (distribution name `capitalcom-cli`) is a command-line client for the
Capital.com Open API, built with Typer + Rich. The CLI is the first-class
surface, but there is also an **experimental** SDK layer at `capital_cli.sdk`
surface, but there is also a **stable (0.x)** SDK layer at `capital_cli.sdk`
(exporting `CapitalComApp`, `CapitalComConfig`, `RiskPolicy`) that embeds the
same tested broker engine in your own Python — see [docs/sdk.md](docs/sdk.md).
The SDK import paths and the `capital_cli.core.models` pydantic models are
documented and intended-stable but may shift between 0.x minors until 1.0. The
`capital_cli.core.*` internals remain **private** — depend on
`capital_cli.sdk` / `capital_cli.services`, not on `core`.
**stable within 0.x** — no breaking changes without a deprecation cycle; breaking
changes are reserved for a future 1.0. The `capital_cli.core.*` internals remain
**private** — depend on `capital_cli.sdk` / `capital_cli.services`, not on `core`.

## Setup & commands

Expand Down Expand Up @@ -44,7 +44,7 @@ CAPCTL_E2E=1 pytest tests/e2e -m e2e -v
- `capital_cli/services/` — presentation-free domain services (markets,
accounts, watchlists, trading, streaming, confirmations) composing `core`;
the reusable broker engine.
- `capital_cli/sdk/` — the experimental public facade (`CapitalComApp`,
- `capital_cli/sdk/` — the public facade (`CapitalComApp`,
`CapitalComConfig`, `RiskPolicy`).
- `capital_cli/cli/` — thin Typer wrappers, one app per command group;
Rich/JSON rendering in `cli/output.py`; the async→exit-code runner in
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.6.0] - 2026-06-14

### Added
- SDK parity: `app.session.server_time()`, `app.session.details()`, and
`app.session.encryption_key()` — the SDK now covers the full Open API surface
(coverage matrix 164/164, no N/A). The CLI `session time`/`details`/
`encryption-key` commands share these methods.

### Changed
- The SDK is now positioned as **stable within 0.x** (no breaking changes without
a deprecation cycle); dropped the "experimental" label across the README,
`docs/sdk.md`, and module docstrings.
- README and `docs/api-coverage.md` now describe **full** (not "main") Open API
coverage, backed by the automated matrix; the coverage badge is relabeled
**CLI + SDK coverage**.

## [0.5.1] - 2026-06-14

### Added
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ It opens and closes a minimum-size BTCUSD position on the **demo** account.
risk engine, models, streaming, state. All safety rules live here (private).
- `capital_cli/services/` — presentation-free domain services (markets, accounts,
watchlists, trading, streaming, confirmations) composing `core`.
- `capital_cli/sdk/` — the experimental public facade (`CapitalComApp`,
- `capital_cli/sdk/` — the public facade (`CapitalComApp`,
`CapitalComConfig`, `RiskPolicy`).
- `capital_cli/cli/` — Typer commands. Thin: parse → call a service → render; the
`cli/` layer never calls the broker API directly.
Expand Down
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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)
[![CLI + SDK 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 Expand Up @@ -39,8 +39,8 @@ The Capital.com web platform is built for clicking; `capctl` is built for **repe
See [practical use cases](docs/use-cases.md) for worked, copy-pasteable scenarios.

**Scope:** `capctl` is first and foremost a command-line application, but the same
tested broker engine is also embeddable as an **experimental** Python SDK — see
[Use as a library](#use-as-a-library-experimental) and [docs/sdk.md](docs/sdk.md).
tested broker engine is also embeddable as a Python SDK — see
[Use as a library](#use-as-a-library) and [docs/sdk.md](docs/sdk.md).
The `capital_cli.core.*` internals remain private and may change between releases.
The CLI itself is short-lived: each command runs as its own process. By default
(`CAP_PERSIST_SESSION=true`) the short-lived session tokens are cached so
Expand All @@ -50,15 +50,15 @@ session login` is mainly a connectivity/account check.

## Features

- **Six command groups** covering the main Capital.com Open API workflows — `session`, `market`, `account`, `trade`, `watchlist`, `stream` (see the [API coverage table](docs/api-coverage.md))
- **Six command groups** covering the **full** Capital.com Open API surface — `session`, `market`, `account`, `trade`, `watchlist`, `stream` — verified by an automated [coverage matrix](docs/api-coverage.md)
- **Safety-first trading** — trading is off by default; enabling it requires an explicit EPIC allowlist, every execution goes through a two-phase *preview → execute* flow with risk checks, and mutating commands require `--yes`
- **`--json` everywhere** — every command can emit raw JSON for piping into `jq`, scripts, or CI jobs
- **Distinct exit codes per failure class** — scripts can branch on *why* a command failed (auth vs. risk-block vs. upstream error)
- **Real-time streaming** — live price tables, price-level alerts, and portfolio snapshots over WebSocket
- **Demo and live environments** — defaults to demo; live requires explicit opt-in
- **Built-in rate limiting** — client-side token buckets respect Capital.com's 10 req/s global, 1 req/s session, and trading-burst limits
- **Session-token caching** — back-to-back commands reuse one login instead of re-authenticating (avoids HTTP 429); on by default, opt out with `CAP_PERSIST_SESSION=false`
- **Embeddable SDK (experimental)** — the same broker engine ships as an async Python library; see [Use as a library](#use-as-a-library-experimental)
- **Embeddable SDK** — the same broker engine ships as an async Python library with a stable 0.x surface; see [Use as a library](#use-as-a-library)

## Installation

Expand Down Expand Up @@ -367,7 +367,7 @@ capctl stream alerts BTCUSD 75000 --direction ABOVE --duration 3600
*/15 * * * * capctl --json trade positions >> ~/logs/positions.jsonl 2>&1
```

## Use as a library (experimental)
## Use as a library

Beyond the CLI, the same broker engine — risk policy, two-phase preview→execute,
rate limiting, WebSocket streaming — is available as an embeddable async SDK.
Expand All @@ -390,9 +390,12 @@ async def main():
asyncio.run(main())
```

The SDK is **experimental** until 1.0: import paths and the pydantic models are
intended-stable but may shift between 0.x minors. See [docs/sdk.md](docs/sdk.md)
for read-only, preview→execute, streaming, and risk-policy examples.
**Stability:** the documented SDK surface — `CapitalComApp`, `CapitalComConfig`,
`RiskPolicy`, the service classes, and the `capital_cli.core.models` pydantic
models — is **stable**: no breaking changes within the 0.x series without a
deprecation cycle; breaking changes are reserved for a future 1.0. The
`capital_cli.core.*` internals remain private. See [docs/sdk.md](docs/sdk.md) for
read-only, preview→execute, streaming, and risk-policy examples.

## Exit codes

Expand Down Expand Up @@ -459,7 +462,7 @@ Or use the task runner: `make check` (lint + typecheck + test), `make docs`, `ma
## Documentation

- [Full CLI reference](docs/CLI.md) — every command and option (auto-generated)
- [Using capctl as a Python SDK](docs/sdk.md) — experimental async SDK, import paths, examples, versioning
- [Using capctl as a Python SDK](docs/sdk.md) — stable async SDK, import paths, examples, versioning
- [Scripting & automation](docs/scripting.md) — CI credentials, exit codes, jq recipes
- [Getting started from zero](docs/getting-started.md) — account, API key, install, first trade
- [Practical use cases](docs/use-cases.md) — what people actually do with capctl, with copy-pasteable scenarios
Expand Down
2 changes: 1 addition & 1 deletion capital_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Capital.com command-line client."""

__version__ = "0.5.1"
__version__ = "0.6.0"
13 changes: 3 additions & 10 deletions capital_cli/cli/session_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import typer

from capital_cli.cli.runner import run
from capital_cli.core.http_client import get_client
from capital_cli.core.session import get_session_manager

app = typer.Typer(no_args_is_help=True, help="Session lifecycle: login, ping, logout.")
Expand Down Expand Up @@ -98,9 +97,7 @@ def time(ctx: typer.Context) -> None:
out = ctx.obj.out

async def _do() -> dict[str, Any]:
client = get_client()
data = (await client.get("/time")).json()
return data if isinstance(data, dict) else {"serverTime": data}
return await get_session_manager().server_time()

out.record(run(out, _do, label="session time"), title="Server time")

Expand All @@ -111,10 +108,7 @@ def details(ctx: typer.Context) -> None:
out = ctx.obj.out

async def _do() -> dict[str, Any]:
sm = get_session_manager()
client = get_client()
await sm.ensure_logged_in()
return (await client.get("/session")).json()
return await get_session_manager().details()

out.record(run(out, _do, label="session details"), title="Session details")

Expand All @@ -125,7 +119,6 @@ def encryption_key(ctx: typer.Context) -> None:
out = ctx.obj.out

async def _do() -> dict[str, Any]:
client = get_client()
return (await client.get("/session/encryptionKey")).json()
return await get_session_manager().encryption_key()

out.record(run(out, _do, label="session encryption-key"), title="Encryption key")
17 changes: 17 additions & 0 deletions capital_cli/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,23 @@ async def ping(self) -> dict[str, Any]:
response = await self.client.get("/ping")
return response.json() if response.text else {"status": "ok"}

async def server_time(self) -> dict[str, Any]:
"""Return the broker's current server time. No authentication required."""
response = await self.client.get("/time")
data = response.json() if response.text else {}
return data if isinstance(data, dict) else {"serverTime": data}

async def details(self) -> dict[str, Any]:
"""Return server-side session details (client id, account id, currency, timezone)."""
await self.ensure_logged_in()
response = await self.client.get("/session")
return response.json() if response.text else {}

async def encryption_key(self) -> dict[str, Any]:
"""Fetch the API encryption key and timestamp (encrypted-password login)."""
response = await self.client.get("/session/encryptionKey")
return response.json() if response.text else {}

async def logout(self) -> None:
"""End session and clear tokens."""
if not self.tokens:
Expand Down
2 changes: 1 addition & 1 deletion capital_cli/sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Capital.com SDK — experimental public API (paths/models stable from 1.0).
"""Capital.com SDK — public API, stable within 0.x (frozen at 1.0).

from capital_cli.sdk import CapitalComApp, CapitalComConfig, RiskPolicy
"""
Expand Down
6 changes: 3 additions & 3 deletions capital_cli/sdk/app.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""CapitalComApp — the SDK facade wiring config + services + risk policy.

EXPERIMENTAL (0.x): one app per process. The underlying services use
process-global singletons, so constructing a second CapitalComApp with a
different config in the same process is not supported yet.
Note (0.x): one app per process. The underlying services use process-global
singletons, so constructing a second CapitalComApp with a different config in the
same process is not supported yet.
"""

from __future__ import annotations
Expand Down
2 changes: 1 addition & 1 deletion capital_cli/sdk/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Public configuration type for the Capital.com SDK (experimental until 1.0)."""
"""Public configuration type for the Capital.com SDK (stable within 0.x)."""

from __future__ import annotations

Expand Down
19 changes: 9 additions & 10 deletions docs/api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,22 @@ issues the request and surfaces the response (via tables or `--json`).

**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).
negative — generated from `tests/e2e/endpoints.py` (regenerate with
`make coverage-doc`). Both the CLI and the SDK cover the **full** Open API surface;
the only surface not exposed as a command is the RSA encrypted-password login flow
(the CLI uses the standard credential login). See the matrix at the bottom of this
file.

<!-- 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.time` | `GET /time` | tested | tested | tested | tested |
| `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.details` | `GET /session` | tested | tested | tested | tested |
| `session.encryption_key` | `GET /session/encryptionKey` | tested | tested | tested | tested |
| `session.login` | `POST /session` | tested | tested | tested | tested |
| `session.switch` | `PUT /session` | tested | tested | tested | tested |
| `session.logout` | `DELETE /session` | tested | tested | tested | tested |
Expand Down Expand Up @@ -123,4 +122,4 @@ standard credential login).
| `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).
**Coverage: 164/164 applicable cells tested (100%) across 41 endpoints — CLI and SDK, positive and negative.**
4 changes: 2 additions & 2 deletions docs/coverage-badge.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"label": "API coverage",
"message": "158/158 (100%)",
"label": "CLI + SDK coverage",
"message": "164/164 (100%)",
"color": "brightgreen"
}
20 changes: 10 additions & 10 deletions docs/sdk.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Using capctl as a Python SDK (experimental)
# Using capctl as a Python SDK

> **EXPERIMENTAL.** The SDK is not yet 1.0. The documented import paths
> (`capital_cli.sdk.{CapitalComApp, CapitalComConfig, RiskPolicy}`) and the
> pydantic models in `capital_cli.core.models` are intended to be stable, but
> they **may shift between 0.x minors** until 1.0. Pin a version if you depend
> on them. See [Versioning](#versioning) below.
> **Stable surface (pre-1.0).** The documented import paths
> (`capital_cli.sdk.{CapitalComApp, CapitalComConfig, RiskPolicy}`), the service
> classes, and the pydantic models in `capital_cli.core.models` are **stable**:
> no breaking changes within the 0.x series without a deprecation cycle. The
> `capital_cli.core.*` internals remain private. Breaking changes are reserved
> for a future 1.0.

`capctl` is first and foremost a CLI, but the same tested broker engine — the
risk policy, the two-phase preview→execute flow, the rate limiter, the
Expand Down Expand Up @@ -204,10 +205,9 @@ raw API responses.

## Versioning

Until 1.0 the SDK is **experimental**: import paths
(`capital_cli.sdk.{CapitalComApp, CapitalComConfig, RiskPolicy}`) and the
pydantic models are documented and intended-stable, but may shift between
0.x minors. From 1.0:
The documented SDK surface (`capital_cli.sdk.{CapitalComApp, CapitalComConfig,
RiskPolicy}`, the service classes, and the `capital_cli.core.models` models) is
**stable within 0.x** — no breaking changes without a deprecation cycle. From 1.0:
- Breaking changes to the SDK/service layer only in **major** versions.
- CLI UX may evolve in minors independently of the SDK contract.
- The `capital_cli.core.*` internals remain private (no compatibility promise);
Expand Down
12 changes: 9 additions & 3 deletions tests/e2e/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ class Endpoint:

# --- The 41-row registry (mirror of docs/api-coverage.md row order) ----------
ENDPOINTS: list[Endpoint] = [
Endpoint("session.time", "GET /time", "session time", None, "session", False),
Endpoint("session.time", "GET /time", "session time", "session.server_time", "session", False),
Endpoint("session.ping", "GET /ping", "session ping", "session.ping", "session", False),
Endpoint("session.details", "GET /session", "session details", None, "session", False),
Endpoint("session.encryption_key", "GET /session/encryptionKey", "session encryption-key", None, "session", False),
Endpoint("session.details", "GET /session", "session details", "session.details", "session", False),
Endpoint("session.encryption_key", "GET /session/encryptionKey", "session encryption-key", "session.encryption_key", "session", False),
Endpoint("session.login", "POST /session", "session login", "session.login", "session", True),
Endpoint("session.switch", "PUT /session", "session switch", "session.switch_account", "session", True),
Endpoint("session.logout", "DELETE /session", "session logout", "session.logout", "session", True),
Expand Down Expand Up @@ -235,6 +235,9 @@ class Cells:
# --- sdk_pos wiring (Task 5): existing sdk-e2e + new gap tests -----------------
_S = "tests/e2e/test_sdk_e2e.py"
_P = "tests/e2e/test_sdk_positive_gaps_e2e.py"
COVERAGE["session.time"].sdk_pos = f"{_P}::test_sdk_session_reads"
COVERAGE["session.details"].sdk_pos = f"{_P}::test_sdk_session_reads"
COVERAGE["session.encryption_key"].sdk_pos = f"{_P}::test_sdk_session_reads"
COVERAGE["session.ping"].sdk_pos = f"{_P}::test_sdk_session_ping_switch_logout"
COVERAGE["session.login"].sdk_pos = f"{_S}::test_sdk_read_only_flow"
COVERAGE["session.switch"].sdk_pos = f"{_P}::test_sdk_session_ping_switch_logout"
Expand Down Expand Up @@ -297,6 +300,9 @@ class Cells:
COVERAGE["watchlist.delete"].sdk_neg = f"{_SN}::test_sdk_nontrade_mutations_require_confirm[watchlist.delete]"
COVERAGE["account.prefs_set"].sdk_neg = f"{_SN}::test_sdk_nontrade_mutations_require_confirm[account.prefs_set]"
COVERAGE["account.topup"].sdk_neg = f"{_SN}::test_sdk_nontrade_mutations_require_confirm[account.topup]"
COVERAGE["session.time"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.time]"
COVERAGE["session.details"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.details]"
COVERAGE["session.encryption_key"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.encryption_key]"
COVERAGE["session.ping"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.ping]"
COVERAGE["session.switch"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.switch]"
COVERAGE["session.logout"].sdk_neg = f"{_SN}::test_sdk_missing_config_raises[session.logout]"
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/test_sdk_negative_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ def _missing_config_env():

@pytest.mark.parametrize(
"endpoint_id",
["session.ping", "session.switch", "session.logout", "session.login",
["session.time", "session.details", "session.encryption_key",
"session.ping", "session.switch", "session.logout", "session.login",
"account.list", "account.prefs_get", "account.history_activity",
"account.history_transactions", "market.search", "market.sentiment",
"market.nav_root", "market.nav_node", "position.list", "order.list",
Expand Down
Loading
Loading