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.
- t212_tui-0.1.0/LICENSE +21 -0
- t212_tui-0.1.0/PKG-INFO +161 -0
- t212_tui-0.1.0/README.md +140 -0
- t212_tui-0.1.0/pyproject.toml +48 -0
- t212_tui-0.1.0/src/t212/__init__.py +1 -0
- t212_tui-0.1.0/src/t212/api/__init__.py +0 -0
- t212_tui-0.1.0/src/t212/api/base.py +40 -0
- t212_tui-0.1.0/src/t212/api/http.py +107 -0
- t212_tui-0.1.0/src/t212/api/limits.py +18 -0
- t212_tui-0.1.0/src/t212/api/mock.py +65 -0
- t212_tui-0.1.0/src/t212/api/ratelimit.py +53 -0
- t212_tui-0.1.0/src/t212/app.py +472 -0
- t212_tui-0.1.0/src/t212/charts.py +11 -0
- t212_tui-0.1.0/src/t212/cli.py +66 -0
- t212_tui-0.1.0/src/t212/config.py +56 -0
- t212_tui-0.1.0/src/t212/formatting.py +54 -0
- t212_tui-0.1.0/src/t212/models.py +265 -0
- t212_tui-0.1.0/src/t212/pagination.py +25 -0
- t212_tui-0.1.0/src/t212/resolve.py +39 -0
- t212_tui-0.1.0/src/t212/sample_data/dividends.json +44 -0
- t212_tui-0.1.0/src/t212/sample_data/exchanges.json +28 -0
- t212_tui-0.1.0/src/t212/sample_data/history_orders.json +61 -0
- t212_tui-0.1.0/src/t212/sample_data/history_orders_page2.json +33 -0
- t212_tui-0.1.0/src/t212/sample_data/instruments.json +5 -0
- t212_tui-0.1.0/src/t212/sample_data/orders.json +42 -0
- t212_tui-0.1.0/src/t212/sample_data/pie_detail.json +32 -0
- t212_tui-0.1.0/src/t212/sample_data/pies.json +18 -0
- t212_tui-0.1.0/src/t212/sample_data/positions.json +32 -0
- t212_tui-0.1.0/src/t212/sample_data/summary.json +12 -0
- t212_tui-0.1.0/src/t212/sample_data/transactions.json +9 -0
- t212_tui-0.1.0/src/t212/scheduler.py +57 -0
- t212_tui-0.1.0/src/t212/screens/__init__.py +0 -0
- t212_tui-0.1.0/src/t212/screens/dashboard.py +157 -0
- t212_tui-0.1.0/src/t212/screens/help.py +35 -0
- t212_tui-0.1.0/src/t212/screens/history.py +135 -0
- t212_tui-0.1.0/src/t212/screens/instrument_detail.py +49 -0
- t212_tui-0.1.0/src/t212/screens/pie_detail.py +77 -0
- t212_tui-0.1.0/src/t212/screens/pies.py +40 -0
- t212_tui-0.1.0/src/t212/screens/position_detail.py +41 -0
- t212_tui-0.1.0/src/t212/screens/positions.py +67 -0
- t212_tui-0.1.0/src/t212/screens/search.py +55 -0
- t212_tui-0.1.0/src/t212/screens/setup.py +121 -0
- t212_tui-0.1.0/src/t212/store.py +96 -0
- t212_tui-0.1.0/src/t212/summary.py +55 -0
- t212_tui-0.1.0/src/t212/theming.py +27 -0
- t212_tui-0.1.0/src/t212/widgets/__init__.py +0 -0
- t212_tui-0.1.0/src/t212/widgets/hintbar.py +24 -0
- t212_tui-0.1.0/src/t212/widgets/render.py +57 -0
- t212_tui-0.1.0/src/t212/widgets/styles.tcss +6 -0
- t212_tui-0.1.0/src/t212/widgets/summary_header.py +48 -0
- 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.
|
t212_tui-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
t212_tui-0.1.0/README.md
ADDED
|
@@ -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)
|