t212-tui 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. t212_tui-0.1.0/LICENSE +21 -0
  2. t212_tui-0.1.0/PKG-INFO +161 -0
  3. t212_tui-0.1.0/README.md +140 -0
  4. t212_tui-0.1.0/pyproject.toml +48 -0
  5. t212_tui-0.1.0/src/t212/__init__.py +1 -0
  6. t212_tui-0.1.0/src/t212/api/__init__.py +0 -0
  7. t212_tui-0.1.0/src/t212/api/base.py +40 -0
  8. t212_tui-0.1.0/src/t212/api/http.py +107 -0
  9. t212_tui-0.1.0/src/t212/api/limits.py +18 -0
  10. t212_tui-0.1.0/src/t212/api/mock.py +65 -0
  11. t212_tui-0.1.0/src/t212/api/ratelimit.py +53 -0
  12. t212_tui-0.1.0/src/t212/app.py +472 -0
  13. t212_tui-0.1.0/src/t212/charts.py +11 -0
  14. t212_tui-0.1.0/src/t212/cli.py +66 -0
  15. t212_tui-0.1.0/src/t212/config.py +56 -0
  16. t212_tui-0.1.0/src/t212/formatting.py +54 -0
  17. t212_tui-0.1.0/src/t212/models.py +265 -0
  18. t212_tui-0.1.0/src/t212/pagination.py +25 -0
  19. t212_tui-0.1.0/src/t212/resolve.py +39 -0
  20. t212_tui-0.1.0/src/t212/sample_data/dividends.json +44 -0
  21. t212_tui-0.1.0/src/t212/sample_data/exchanges.json +28 -0
  22. t212_tui-0.1.0/src/t212/sample_data/history_orders.json +61 -0
  23. t212_tui-0.1.0/src/t212/sample_data/history_orders_page2.json +33 -0
  24. t212_tui-0.1.0/src/t212/sample_data/instruments.json +5 -0
  25. t212_tui-0.1.0/src/t212/sample_data/orders.json +42 -0
  26. t212_tui-0.1.0/src/t212/sample_data/pie_detail.json +32 -0
  27. t212_tui-0.1.0/src/t212/sample_data/pies.json +18 -0
  28. t212_tui-0.1.0/src/t212/sample_data/positions.json +32 -0
  29. t212_tui-0.1.0/src/t212/sample_data/summary.json +12 -0
  30. t212_tui-0.1.0/src/t212/sample_data/transactions.json +9 -0
  31. t212_tui-0.1.0/src/t212/scheduler.py +57 -0
  32. t212_tui-0.1.0/src/t212/screens/__init__.py +0 -0
  33. t212_tui-0.1.0/src/t212/screens/dashboard.py +157 -0
  34. t212_tui-0.1.0/src/t212/screens/help.py +35 -0
  35. t212_tui-0.1.0/src/t212/screens/history.py +135 -0
  36. t212_tui-0.1.0/src/t212/screens/instrument_detail.py +49 -0
  37. t212_tui-0.1.0/src/t212/screens/pie_detail.py +77 -0
  38. t212_tui-0.1.0/src/t212/screens/pies.py +40 -0
  39. t212_tui-0.1.0/src/t212/screens/position_detail.py +41 -0
  40. t212_tui-0.1.0/src/t212/screens/positions.py +67 -0
  41. t212_tui-0.1.0/src/t212/screens/search.py +55 -0
  42. t212_tui-0.1.0/src/t212/screens/setup.py +121 -0
  43. t212_tui-0.1.0/src/t212/store.py +96 -0
  44. t212_tui-0.1.0/src/t212/summary.py +55 -0
  45. t212_tui-0.1.0/src/t212/theming.py +27 -0
  46. t212_tui-0.1.0/src/t212/widgets/__init__.py +0 -0
  47. t212_tui-0.1.0/src/t212/widgets/hintbar.py +24 -0
  48. t212_tui-0.1.0/src/t212/widgets/render.py +57 -0
  49. t212_tui-0.1.0/src/t212/widgets/styles.tcss +6 -0
  50. t212_tui-0.1.0/src/t212/widgets/summary_header.py +48 -0
  51. t212_tui-0.1.0/src/t212/widgets/tabbar.py +28 -0
t212_tui-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shadowhusky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: t212-tui
3
+ Version: 0.1.0
4
+ Summary: Read-only Trading 212 portfolio terminal
5
+ Keywords: trading212,tui,terminal,portfolio,textual
6
+ Author: shadowhusky
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: Office/Business :: Financial :: Investment
13
+ Requires-Dist: textual>=0.85
14
+ Requires-Dist: textual-plotext>=0.2.1
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: pydantic>=2.6
17
+ Requires-Dist: click>=8.1
18
+ Requires-Python: >=3.11
19
+ Project-URL: Repository, https://github.com/Shadowhusky/t212
20
+ Description-Content-Type: text/markdown
21
+
22
+ # t212
23
+
24
+ A terminal dashboard for your Trading 212 account. Read-only by design: it can
25
+ show you everything and touch nothing.
26
+
27
+ ```
28
+ t212 ● live · LIVE · GBP Wed 10 Jun 2026 · 14:32:05
29
+ Portfolio value £24,813.07 Today ▲ +£86.20 Free £312.40
30
+ ──────────────────────────────────────────────────────────────────────────────
31
+ 1 Dashboard 2 Positions 3 Pies 4 History 5 Search
32
+ ──────────────────────────────────────────────────────────────────────────────
33
+ INVESTMENTS CASH
34
+ Value £24,395.17 Available £312.40
35
+ Cost £23,190.84 In pies £105.50
36
+ Unrealised ▲ +£1,204.33 +5.19% Reserved £0.00
37
+ Realised ▲ +£430.11
38
+
39
+ INCOME DEPOSITS
40
+ Dividends £16.30 Net deposits £23,300.00
41
+ Interest £1.95 Gain vs in ▲ +£1,513.07
42
+
43
+ EQUITY · since first run
44
+ ▁▂▂▃▃▄▄▅▅▆▆▇▇████▇▇▆▆▇▇████
45
+ ```
46
+
47
+ I wanted to check my portfolio without opening the app or a browser tab, and
48
+ without ever worrying that a stray keypress could place an order. So: a TUI
49
+ that polls the public API politely, renders everything worth knowing, and has
50
+ no code path that can mutate the account. The only HTTP verb in the client
51
+ is `GET`.
52
+
53
+ ## What it shows
54
+
55
+ - **Dashboard** – account value, unrealised and realised P&L, cash breakdown
56
+ (available / in pies / reserved), pending orders, dividend & interest income,
57
+ net deposits vs. current value, allocation, top movers, equity curve.
58
+ - **Positions** – sortable table with value, P&L, FX impact and weight, plus a
59
+ detail view per holding. Quantity held inside pies is marked.
60
+ - **Pies** – each AutoInvest pie with its return, dividends and goal progress;
61
+ drill in for target-vs-actual drift per instrument and any flagged issues.
62
+ - **History** – orders with realised P&L and fees, dividends with running
63
+ totals (cash interest included), deposits and withdrawals with a running
64
+ balance. `m` pages further back.
65
+ - **Search** – the full tradeable universe, filtered as you type. Holdings are
66
+ flagged; the detail view shows market hours.
67
+ - **Equity curve** – the API exposes no account-value history, so t212 records
68
+ a snapshot locally (SQLite) each time it polls. The chart grows the longer
69
+ you use it and is labelled "since first run"; nothing is back-filled.
70
+
71
+ Three themes (dark, light, high-contrast), a privacy blur (`z`) for
72
+ screen-sharing, and gains/losses always carry an arrow and a sign, never
73
+ colour alone.
74
+
75
+ ## Install
76
+
77
+ Needs Python 3.11+ and [uv](https://docs.astral.sh/uv/).
78
+
79
+ ```sh
80
+ git clone https://github.com/Shadowhusky/t212.git
81
+ cd t212
82
+ uv sync
83
+ uv run t212
84
+ ```
85
+
86
+ First run opens a guided setup: paste your API key ID and secret, pick live or
87
+ demo, and it validates against the API before saving. There's also a
88
+ "browse sample data" mode if you just want to poke around the UI first.
89
+
90
+ To get a key: Trading 212 app → **Settings → API (Beta)** → generate. Enable
91
+ the read scopes (Account, Portfolio, Pies, Metadata, History). *Orders read*
92
+ is optional — it powers the pending-orders panel. t212 works with Invest and
93
+ Stocks ISA accounts.
94
+
95
+ Prefer configuring outside the TUI? Both of these work too:
96
+
97
+ ```sh
98
+ export TRADING212_API_KEY="<keyId>:<secret>" # env var
99
+ uv run t212 config set-key # prompt, saved chmod 600
100
+ ```
101
+
102
+ ## Usage
103
+
104
+ ```sh
105
+ uv run t212 # live account
106
+ uv run t212 --demo # practice account
107
+ uv run t212 --mock # sample data, no key needed
108
+ uv run t212 --once # plain-text summary to stdout, then exit
109
+ uv run t212 --refresh 15 # poll interval in seconds
110
+ ```
111
+
112
+ | Key | Action |
113
+ | --- | --- |
114
+ | `1`–`5` | Switch tab |
115
+ | `↑`/`↓`, `j`/`k` | Move |
116
+ | `Enter` / `Esc` | Open / close detail |
117
+ | `←` `→` | History section |
118
+ | `m` | Load more (History) |
119
+ | `s` | Sort (Positions) |
120
+ | `z` | Privacy blur |
121
+ | `t` | Theme |
122
+ | `r` | Refresh now |
123
+ | `?` | Help |
124
+ | `q` | Quit |
125
+
126
+ ## Notes on behaviour
127
+
128
+ - Talks to the current `/api/v0` surface (account summary, positions, pies,
129
+ orders, equity history) with HTTP Basic `keyId:secret` auth.
130
+ - The API is REST-only, so prices are polled — each endpoint on its own
131
+ cadence within its documented rate limit, with jitter, and automatic
132
+ back-off on `429`.
133
+ - Only the active tab's data is polled.
134
+ - Credentials live in `~/.config/t212/config.toml` (chmod 600) and are sent
135
+ nowhere except trading212.com. They are never logged or rendered.
136
+ - Snapshots are stored per account in `~/.local/share/t212/`.
137
+
138
+ ## Development
139
+
140
+ ```sh
141
+ uv run pytest -q # 109 tests, fixture-driven, no network
142
+ uv run t212 --mock # full UI offline
143
+ ```
144
+
145
+ ```
146
+ src/t212/
147
+ models.py pydantic models for the API
148
+ api/ client protocol · httpx client · mock client · rate limiter
149
+ scheduler.py per-tab polling
150
+ store.py sqlite snapshots + instrument cache
151
+ app.py Textual app shell
152
+ screens/ dashboard · positions · pies · history · search · setup · details
153
+ widgets/ header · tab bar · render primitives
154
+ ```
155
+
156
+ ## Disclaimer
157
+
158
+ Unofficial, not affiliated with Trading 212. The API is in beta and may
159
+ change. Not financial advice; use at your own risk.
160
+
161
+ [MIT](LICENSE) © shadowhusky
@@ -0,0 +1,140 @@
1
+ # t212
2
+
3
+ A terminal dashboard for your Trading 212 account. Read-only by design: it can
4
+ show you everything and touch nothing.
5
+
6
+ ```
7
+ t212 ● live · LIVE · GBP Wed 10 Jun 2026 · 14:32:05
8
+ Portfolio value £24,813.07 Today ▲ +£86.20 Free £312.40
9
+ ──────────────────────────────────────────────────────────────────────────────
10
+ 1 Dashboard 2 Positions 3 Pies 4 History 5 Search
11
+ ──────────────────────────────────────────────────────────────────────────────
12
+ INVESTMENTS CASH
13
+ Value £24,395.17 Available £312.40
14
+ Cost £23,190.84 In pies £105.50
15
+ Unrealised ▲ +£1,204.33 +5.19% Reserved £0.00
16
+ Realised ▲ +£430.11
17
+
18
+ INCOME DEPOSITS
19
+ Dividends £16.30 Net deposits £23,300.00
20
+ Interest £1.95 Gain vs in ▲ +£1,513.07
21
+
22
+ EQUITY · since first run
23
+ ▁▂▂▃▃▄▄▅▅▆▆▇▇████▇▇▆▆▇▇████
24
+ ```
25
+
26
+ I wanted to check my portfolio without opening the app or a browser tab, and
27
+ without ever worrying that a stray keypress could place an order. So: a TUI
28
+ that polls the public API politely, renders everything worth knowing, and has
29
+ no code path that can mutate the account. The only HTTP verb in the client
30
+ is `GET`.
31
+
32
+ ## What it shows
33
+
34
+ - **Dashboard** – account value, unrealised and realised P&L, cash breakdown
35
+ (available / in pies / reserved), pending orders, dividend & interest income,
36
+ net deposits vs. current value, allocation, top movers, equity curve.
37
+ - **Positions** – sortable table with value, P&L, FX impact and weight, plus a
38
+ detail view per holding. Quantity held inside pies is marked.
39
+ - **Pies** – each AutoInvest pie with its return, dividends and goal progress;
40
+ drill in for target-vs-actual drift per instrument and any flagged issues.
41
+ - **History** – orders with realised P&L and fees, dividends with running
42
+ totals (cash interest included), deposits and withdrawals with a running
43
+ balance. `m` pages further back.
44
+ - **Search** – the full tradeable universe, filtered as you type. Holdings are
45
+ flagged; the detail view shows market hours.
46
+ - **Equity curve** – the API exposes no account-value history, so t212 records
47
+ a snapshot locally (SQLite) each time it polls. The chart grows the longer
48
+ you use it and is labelled "since first run"; nothing is back-filled.
49
+
50
+ Three themes (dark, light, high-contrast), a privacy blur (`z`) for
51
+ screen-sharing, and gains/losses always carry an arrow and a sign, never
52
+ colour alone.
53
+
54
+ ## Install
55
+
56
+ Needs Python 3.11+ and [uv](https://docs.astral.sh/uv/).
57
+
58
+ ```sh
59
+ git clone https://github.com/Shadowhusky/t212.git
60
+ cd t212
61
+ uv sync
62
+ uv run t212
63
+ ```
64
+
65
+ First run opens a guided setup: paste your API key ID and secret, pick live or
66
+ demo, and it validates against the API before saving. There's also a
67
+ "browse sample data" mode if you just want to poke around the UI first.
68
+
69
+ To get a key: Trading 212 app → **Settings → API (Beta)** → generate. Enable
70
+ the read scopes (Account, Portfolio, Pies, Metadata, History). *Orders read*
71
+ is optional — it powers the pending-orders panel. t212 works with Invest and
72
+ Stocks ISA accounts.
73
+
74
+ Prefer configuring outside the TUI? Both of these work too:
75
+
76
+ ```sh
77
+ export TRADING212_API_KEY="<keyId>:<secret>" # env var
78
+ uv run t212 config set-key # prompt, saved chmod 600
79
+ ```
80
+
81
+ ## Usage
82
+
83
+ ```sh
84
+ uv run t212 # live account
85
+ uv run t212 --demo # practice account
86
+ uv run t212 --mock # sample data, no key needed
87
+ uv run t212 --once # plain-text summary to stdout, then exit
88
+ uv run t212 --refresh 15 # poll interval in seconds
89
+ ```
90
+
91
+ | Key | Action |
92
+ | --- | --- |
93
+ | `1`–`5` | Switch tab |
94
+ | `↑`/`↓`, `j`/`k` | Move |
95
+ | `Enter` / `Esc` | Open / close detail |
96
+ | `←` `→` | History section |
97
+ | `m` | Load more (History) |
98
+ | `s` | Sort (Positions) |
99
+ | `z` | Privacy blur |
100
+ | `t` | Theme |
101
+ | `r` | Refresh now |
102
+ | `?` | Help |
103
+ | `q` | Quit |
104
+
105
+ ## Notes on behaviour
106
+
107
+ - Talks to the current `/api/v0` surface (account summary, positions, pies,
108
+ orders, equity history) with HTTP Basic `keyId:secret` auth.
109
+ - The API is REST-only, so prices are polled — each endpoint on its own
110
+ cadence within its documented rate limit, with jitter, and automatic
111
+ back-off on `429`.
112
+ - Only the active tab's data is polled.
113
+ - Credentials live in `~/.config/t212/config.toml` (chmod 600) and are sent
114
+ nowhere except trading212.com. They are never logged or rendered.
115
+ - Snapshots are stored per account in `~/.local/share/t212/`.
116
+
117
+ ## Development
118
+
119
+ ```sh
120
+ uv run pytest -q # 109 tests, fixture-driven, no network
121
+ uv run t212 --mock # full UI offline
122
+ ```
123
+
124
+ ```
125
+ src/t212/
126
+ models.py pydantic models for the API
127
+ api/ client protocol · httpx client · mock client · rate limiter
128
+ scheduler.py per-tab polling
129
+ store.py sqlite snapshots + instrument cache
130
+ app.py Textual app shell
131
+ screens/ dashboard · positions · pies · history · search · setup · details
132
+ widgets/ header · tab bar · render primitives
133
+ ```
134
+
135
+ ## Disclaimer
136
+
137
+ Unofficial, not affiliated with Trading 212. The API is in beta and may
138
+ change. Not financial advice; use at your own risk.
139
+
140
+ [MIT](LICENSE) © shadowhusky
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "t212-tui"
3
+ version = "0.1.0"
4
+ description = "Read-only Trading 212 portfolio terminal"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "shadowhusky" }]
9
+ keywords = ["trading212", "tui", "terminal", "portfolio", "textual"]
10
+ classifiers = [
11
+ "Environment :: Console",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Topic :: Office/Business :: Financial :: Investment",
15
+ ]
16
+ requires-python = ">=3.11"
17
+ dependencies = [
18
+ "textual>=0.85",
19
+ "textual-plotext>=0.2.1",
20
+ "httpx>=0.27",
21
+ "pydantic>=2.6",
22
+ "click>=8.1",
23
+ ]
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/Shadowhusky/t212"
27
+
28
+ [project.scripts]
29
+ t212 = "t212.cli:main"
30
+ t212-tui = "t212.cli:main"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "pytest>=8",
35
+ "pytest-asyncio>=0.23",
36
+ "textual-dev>=1.8.0",
37
+ ]
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
41
+ testpaths = ["tests"]
42
+
43
+ [tool.uv.build-backend]
44
+ module-name = "t212"
45
+
46
+ [build-system]
47
+ requires = ["uv_build>=0.11.7,<0.12.0"]
48
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+ from typing import Protocol
3
+ from t212.models import (AccountSummary, Position, PendingOrder, Pie, PieDetail,
4
+ TradableInstrument, Exchange, HistoricalOrder, Dividend, Transaction)
5
+ from t212.pagination import Page
6
+
7
+
8
+ class ApiError(Exception):
9
+ pass
10
+
11
+
12
+ class AuthError(ApiError):
13
+ pass
14
+
15
+
16
+ class ScopeError(ApiError):
17
+ pass
18
+
19
+
20
+ class RateLimited(ApiError):
21
+ def __init__(self, retry_after: float):
22
+ super().__init__(f"rate limited, retry in {retry_after:.0f}s")
23
+ self.retry_after = retry_after
24
+
25
+
26
+ class T212Client(Protocol):
27
+ async def summary(self) -> AccountSummary: ...
28
+ async def positions(self) -> list[Position]: ...
29
+ async def orders(self) -> list[PendingOrder]: ...
30
+ async def pies(self) -> list[Pie]: ...
31
+ async def pie(self, pie_id: int) -> PieDetail: ...
32
+ async def instruments(self) -> list[TradableInstrument]: ...
33
+ async def exchanges(self) -> list[Exchange]: ...
34
+ async def history_orders(self, cursor: str | None = None,
35
+ ticker: str | None = None) -> Page[HistoricalOrder]: ...
36
+ async def dividends(self, cursor: str | None = None,
37
+ ticker: str | None = None) -> Page[Dividend]: ...
38
+ async def transactions(self, cursor: str | None = None) -> Page[Transaction]: ...
39
+ async def get_page(self, path: str) -> dict: ...
40
+ async def aclose(self) -> None: ...
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+ import time
3
+ import httpx
4
+ from t212.api.base import ApiError, AuthError, RateLimited, ScopeError
5
+ from t212.api.ratelimit import RateLimitGovernor
6
+ from t212.models import (AccountSummary, Position, PendingOrder, Pie, PieDetail,
7
+ TradableInstrument, Exchange, HistoricalOrder, Dividend, Transaction)
8
+ from t212.pagination import Page, parse_cursor
9
+
10
+ V = "/api/v0"
11
+
12
+
13
+ def _limit_key_for_path(path: str) -> str:
14
+ if "history/orders" in path:
15
+ return "history_orders"
16
+ if "dividends" in path:
17
+ return "dividends"
18
+ return "transactions"
19
+
20
+
21
+ class HttpT212Client:
22
+ def __init__(self, *, api_key: str, base_url: str, governor: RateLimitGovernor,
23
+ client: httpx.AsyncClient | None = None, timeout: float = 15.0):
24
+ self._gov = governor
25
+ if client is not None:
26
+ self._client = client
27
+ elif ":" in api_key: # current format: keyId:secret → HTTP Basic
28
+ key_id, secret = api_key.split(":", 1)
29
+ self._client = httpx.AsyncClient(
30
+ base_url=base_url, auth=httpx.BasicAuth(key_id, secret), timeout=timeout)
31
+ else: # legacy single-key header
32
+ self._client = httpx.AsyncClient(
33
+ base_url=base_url, headers={"Authorization": api_key}, timeout=timeout)
34
+
35
+ async def _get(self, limit_key: str, path: str, params: dict | None = None):
36
+ await self._gov.acquire(limit_key)
37
+ try:
38
+ r = await self._client.get(path, params=params)
39
+ except httpx.HTTPError as e:
40
+ raise ApiError(str(e)) from e
41
+ if r.status_code == 429:
42
+ raw = float(r.headers.get("x-ratelimit-reset", "5"))
43
+ # header may be a unix timestamp rather than seconds-from-now
44
+ retry = max(1.0, raw - time.time()) if raw > 1e9 else raw
45
+ self._gov.note_server_reset(limit_key, retry)
46
+ raise RateLimited(retry)
47
+ if r.status_code == 401:
48
+ raise AuthError("unauthorized — check API key or live/demo environment")
49
+ if r.status_code == 403:
50
+ raise ScopeError("API key missing a required scope for this endpoint")
51
+ if r.status_code >= 400:
52
+ raise ApiError(f"HTTP {r.status_code} for {path}")
53
+ return r.json()
54
+
55
+ async def summary(self) -> AccountSummary:
56
+ return AccountSummary.model_validate(await self._get("summary", f"{V}/equity/account/summary"))
57
+
58
+ async def positions(self) -> list[Position]:
59
+ return [Position.model_validate(x) for x in await self._get("positions", f"{V}/equity/positions")]
60
+
61
+ async def orders(self) -> list[PendingOrder]:
62
+ return [PendingOrder.model_validate(x) for x in await self._get("orders", f"{V}/equity/orders")]
63
+
64
+ async def pies(self) -> list[Pie]:
65
+ return [Pie.model_validate(x) for x in await self._get("pies", f"{V}/equity/pies")]
66
+
67
+ async def pie(self, pie_id: int) -> PieDetail:
68
+ return PieDetail.model_validate(await self._get("pie", f"{V}/equity/pies/{pie_id}"))
69
+
70
+ async def instruments(self) -> list[TradableInstrument]:
71
+ return [TradableInstrument.model_validate(x)
72
+ for x in await self._get("instruments", f"{V}/equity/metadata/instruments")]
73
+
74
+ async def exchanges(self) -> list[Exchange]:
75
+ return [Exchange.model_validate(x) for x in await self._get("exchanges", f"{V}/equity/metadata/exchanges")]
76
+
77
+ async def _page(self, limit_key: str, path: str, model,
78
+ cursor: str | None, ticker: str | None = None):
79
+ params: dict = {"limit": 50}
80
+ if cursor:
81
+ params["cursor"] = cursor
82
+ if ticker:
83
+ params["ticker"] = ticker
84
+ raw = await self._get(limit_key, path, params=params)
85
+ next_path = raw.get("nextPagePath")
86
+ return Page(items=[model.model_validate(x) for x in raw.get("items", [])],
87
+ next_cursor=parse_cursor(next_path), next_path=next_path)
88
+
89
+ async def history_orders(self, cursor: str | None = None,
90
+ ticker: str | None = None) -> Page[HistoricalOrder]:
91
+ return await self._page("history_orders", f"{V}/equity/history/orders",
92
+ HistoricalOrder, cursor, ticker)
93
+
94
+ async def dividends(self, cursor: str | None = None,
95
+ ticker: str | None = None) -> Page[Dividend]:
96
+ return await self._page("dividends", f"{V}/equity/history/dividends",
97
+ Dividend, cursor, ticker)
98
+
99
+ async def transactions(self, cursor: str | None = None) -> Page[Transaction]:
100
+ return await self._page("transactions", f"{V}/equity/history/transactions",
101
+ Transaction, cursor)
102
+
103
+ async def get_page(self, path: str) -> dict:
104
+ return await self._get(_limit_key_for_path(path), path)
105
+
106
+ async def aclose(self) -> None:
107
+ await self._client.aclose()
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ LIVE_URL = "https://live.trading212.com"
4
+ DEMO_URL = "https://demo.trading212.com"
5
+
6
+ # key: (capacity, per_seconds) — mirrors documented limits; honours x-ratelimit-reset at runtime
7
+ RATE_LIMITS: dict[str, tuple[int, float]] = {
8
+ "summary": (1, 5.0),
9
+ "positions": (1, 1.0),
10
+ "orders": (1, 5.0),
11
+ "pies": (1, 30.0),
12
+ "pie": (1, 5.0),
13
+ "history_orders": (6, 60.0),
14
+ "dividends": (6, 60.0),
15
+ "transactions": (6, 60.0),
16
+ "instruments": (1, 50.0),
17
+ "exchanges": (1, 30.0),
18
+ }
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import pathlib
4
+ from t212.models import (AccountSummary, Position, PendingOrder, Pie, PieDetail,
5
+ TradableInstrument, Exchange, HistoricalOrder, Dividend, Transaction)
6
+ from t212.pagination import Page, parse_cursor
7
+
8
+ SAMPLE_DIR = pathlib.Path(__file__).parent.parent / "sample_data"
9
+
10
+
11
+ class MockT212Client:
12
+ def __init__(self, fixtures_dir: str | pathlib.Path | None = None):
13
+ self._dir = pathlib.Path(fixtures_dir) if fixtures_dir else SAMPLE_DIR
14
+
15
+ def _load(self, name: str):
16
+ return json.loads((self._dir / f"{name}.json").read_text())
17
+
18
+ async def summary(self) -> AccountSummary:
19
+ return AccountSummary.model_validate(self._load("summary"))
20
+
21
+ async def positions(self) -> list[Position]:
22
+ return [Position.model_validate(x) for x in self._load("positions")]
23
+
24
+ async def orders(self) -> list[PendingOrder]:
25
+ try:
26
+ return [PendingOrder.model_validate(x) for x in self._load("orders")]
27
+ except FileNotFoundError:
28
+ return []
29
+
30
+ async def pies(self) -> list[Pie]:
31
+ return [Pie.model_validate(x) for x in self._load("pies")]
32
+
33
+ async def pie(self, pie_id: int) -> PieDetail:
34
+ return PieDetail.model_validate(self._load("pie_detail"))
35
+
36
+ async def instruments(self) -> list[TradableInstrument]:
37
+ return [TradableInstrument.model_validate(x) for x in self._load("instruments")]
38
+
39
+ async def exchanges(self) -> list[Exchange]:
40
+ return [Exchange.model_validate(x) for x in self._load("exchanges")]
41
+
42
+ def _page(self, name: str, model):
43
+ raw = self._load(name)
44
+ next_path = raw.get("nextPagePath")
45
+ return Page(items=[model.model_validate(x) for x in raw["items"]],
46
+ next_cursor=parse_cursor(next_path), next_path=next_path)
47
+
48
+ async def history_orders(self, cursor: str | None = None,
49
+ ticker: str | None = None) -> Page[HistoricalOrder]:
50
+ return self._page("history_orders", HistoricalOrder)
51
+
52
+ async def dividends(self, cursor: str | None = None,
53
+ ticker: str | None = None) -> Page[Dividend]:
54
+ return self._page("dividends", Dividend)
55
+
56
+ async def transactions(self, cursor: str | None = None) -> Page[Transaction]:
57
+ return self._page("transactions", Transaction)
58
+
59
+ async def get_page(self, path: str) -> dict:
60
+ if "history/orders" in path:
61
+ return self._load("history_orders_page2")
62
+ return {"items": [], "nextPagePath": None}
63
+
64
+ async def aclose(self) -> None:
65
+ return None
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import random
4
+ import time
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class _Bucket:
10
+ capacity: float
11
+ per_seconds: float
12
+ tokens: float
13
+ updated: float
14
+
15
+
16
+ class RateLimitGovernor:
17
+ """Per-endpoint token bucket; honours server x-ratelimit-reset; jittered waits."""
18
+
19
+ def __init__(self, limits, *, clock=time.monotonic, sleep=asyncio.sleep, rng=random.random, jitter=0.25):
20
+ now = clock()
21
+ self._buckets = {k: _Bucket(c, s, c, now) for k, (c, s) in limits.items()}
22
+ self._clock = clock
23
+ self._sleep = sleep
24
+ self._rng = rng
25
+ self._jitter = jitter
26
+ self._reset_until: dict[str, float] = {}
27
+
28
+ def _refill(self, b: _Bucket, now: float) -> None:
29
+ elapsed = max(0.0, now - b.updated)
30
+ b.tokens = min(b.capacity, b.tokens + elapsed * (b.capacity / b.per_seconds))
31
+ b.updated = now
32
+
33
+ async def acquire(self, key: str) -> None:
34
+ b = self._buckets[key]
35
+ while True:
36
+ now = self._clock()
37
+ until = self._reset_until.get(key, 0.0)
38
+ if until > now:
39
+ await self._sleep(until - now)
40
+ now = self._clock()
41
+ self._reset_until.pop(key, None)
42
+ b.tokens = b.capacity
43
+ b.updated = now
44
+ self._refill(b, now)
45
+ if b.tokens >= 1.0:
46
+ b.tokens -= 1.0
47
+ return
48
+ deficit = 1.0 - b.tokens
49
+ wait = deficit * (b.per_seconds / b.capacity)
50
+ await self._sleep(wait * (1.0 + self._rng() * self._jitter))
51
+
52
+ def note_server_reset(self, key: str, seconds_from_now: float) -> None:
53
+ self._reset_until[key] = self._clock() + max(0.0, seconds_from_now)