finanzen-zero-api 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.
- finanzen_zero_api-0.1.0/LICENSE +21 -0
- finanzen_zero_api-0.1.0/PKG-INFO +655 -0
- finanzen_zero_api-0.1.0/README.md +628 -0
- finanzen_zero_api-0.1.0/pyproject.toml +56 -0
- finanzen_zero_api-0.1.0/src/zero_api/__init__.py +5 -0
- finanzen_zero_api-0.1.0/src/zero_api/account/__init__.py +1 -0
- finanzen_zero_api-0.1.0/src/zero_api/account/api.py +14 -0
- finanzen_zero_api-0.1.0/src/zero_api/account/models.py +41 -0
- finanzen_zero_api-0.1.0/src/zero_api/auth/__init__.py +1 -0
- finanzen_zero_api-0.1.0/src/zero_api/auth/api.py +133 -0
- finanzen_zero_api-0.1.0/src/zero_api/auth/models.py +74 -0
- finanzen_zero_api-0.1.0/src/zero_api/auth/srp.py +137 -0
- finanzen_zero_api-0.1.0/src/zero_api/client.py +45 -0
- finanzen_zero_api-0.1.0/src/zero_api/core/__init__.py +1 -0
- finanzen_zero_api-0.1.0/src/zero_api/core/exceptions.py +27 -0
- finanzen_zero_api-0.1.0/src/zero_api/core/http.py +216 -0
- finanzen_zero_api-0.1.0/src/zero_api/instrument/__init__.py +5 -0
- finanzen_zero_api-0.1.0/src/zero_api/instrument/api.py +59 -0
- finanzen_zero_api-0.1.0/src/zero_api/instrument/models.py +330 -0
- finanzen_zero_api-0.1.0/src/zero_api/ordering/__init__.py +0 -0
- finanzen_zero_api-0.1.0/src/zero_api/ordering/api.py +92 -0
- finanzen_zero_api-0.1.0/src/zero_api/ordering/models.py +213 -0
- finanzen_zero_api-0.1.0/src/zero_api/portfolio/__init__.py +5 -0
- finanzen_zero_api-0.1.0/src/zero_api/portfolio/api.py +22 -0
- finanzen_zero_api-0.1.0/src/zero_api/portfolio/models.py +71 -0
- finanzen_zero_api-0.1.0/src/zero_api/py.typed +0 -0
- finanzen_zero_api-0.1.0/src/zero_api/sessiontan/api.py +109 -0
- finanzen_zero_api-0.1.0/src/zero_api/sessiontan/models.py +29 -0
- finanzen_zero_api-0.1.0/src/zero_api/watchlist/__init__.py +5 -0
- finanzen_zero_api-0.1.0/src/zero_api/watchlist/api.py +33 -0
- finanzen_zero_api-0.1.0/src/zero_api/watchlist/models.py +61 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Klaus Deja
|
|
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,655 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finanzen-zero-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python client for Finanzen.net ZERO
|
|
5
|
+
Keywords: finanzen.net,zero,broker,trading,api-client
|
|
6
|
+
Author: Klaus Deja
|
|
7
|
+
Author-email: Klaus Deja <klaus.deja@easesolutions.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: httpx>=0.28.1
|
|
20
|
+
Requires-Dist: pydantic>=2.14.1
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Project-URL: Homepage, https://github.com/wtfuii/zero-api
|
|
23
|
+
Project-URL: Repository, https://github.com/wtfuii/zero-api
|
|
24
|
+
Project-URL: Issues, https://github.com/wtfuii/zero-api/issues
|
|
25
|
+
Project-URL: Releases, https://github.com/wtfuii/zero-api/releases
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<h1 align="center">zero-api</h1>
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<strong>Unofficial Python client for the German broker Finanzen.net ZERO</strong> 🇩🇪<br>
|
|
32
|
+
Typed, synchronous, and pure HTTP — no browser, no Selenium, no Playwright.
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<p align="center">
|
|
36
|
+
<a href="https://github.com/wtfuii/zero-api/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/wtfuii/zero-api/actions/workflows/ci.yml/badge.svg"></a>
|
|
37
|
+
<a href="https://pypi.org/project/finanzen-zero-api/"><img alt="PyPI" src="https://img.shields.io/pypi/v/finanzen-zero-api?logo=pypi&logoColor=white"></a>
|
|
38
|
+
<a href="https://www.python.org/downloads/"><img alt="Python 3.13+" src="https://img.shields.io/badge/python-3.13%2B-3776AB?logo=python&logoColor=white"></a>
|
|
39
|
+
<a href="https://mypy.readthedocs.io/en/stable/"><img alt="mypy: strict" src="https://img.shields.io/badge/mypy-strict-2A6DB2"></a>
|
|
40
|
+
<a href="#development"><img alt="Tests: offline only" src="https://img.shields.io/badge/tests-offline%20only-success"></a>
|
|
41
|
+
<a href="https://github.com/wtfuii/zero-api/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-yellow.svg"></a>
|
|
42
|
+
<a href="#project-status"><img alt="Status: alpha" src="https://img.shields.io/badge/status-alpha-orange.svg"></a>
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
> **Unofficial and unsupported.** Finanzen.net ZERO has no public API. This library mimics the
|
|
46
|
+
> broker's private web/app endpoints, which can change at any time. Use it at your own risk,
|
|
47
|
+
> only on an account you are allowed to automate, and never with money you cannot afford to lose.
|
|
48
|
+
|
|
49
|
+
`zero-api` wraps the core workflows of the ZERO web app in one typed Python object: account data,
|
|
50
|
+
instrument search and charts, depot (securities account) positions, the watchlist, order
|
|
51
|
+
placement/cancellation, and the app-push two-factor authentication (2FA) that unlocks order
|
|
52
|
+
execution.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from zero_api import ZeroClient
|
|
56
|
+
|
|
57
|
+
client = ZeroClient(email="you@example.com", password="secret")
|
|
58
|
+
print(client.account.get().buying_power)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Contents
|
|
64
|
+
|
|
65
|
+
- [Features](#features)
|
|
66
|
+
- [What's implemented](#whats-implemented)
|
|
67
|
+
- [Requirements](#requirements)
|
|
68
|
+
- [Installation](#installation)
|
|
69
|
+
- [Quick start](#quick-start)
|
|
70
|
+
- [Authentication and sessions](#authentication-and-sessions)
|
|
71
|
+
- [API guide](#api-guide)
|
|
72
|
+
- [Cash account](#cash-account)
|
|
73
|
+
- [Instruments](#instruments)
|
|
74
|
+
- [Portfolio](#portfolio)
|
|
75
|
+
- [Watchlist](#watchlist)
|
|
76
|
+
- [Orders](#orders)
|
|
77
|
+
- [2FA order unlock](#2fa-order-unlock)
|
|
78
|
+
- [Error handling](#error-handling)
|
|
79
|
+
- [Project status](#project-status)
|
|
80
|
+
- [Limitations and gotchas](#limitations-and-gotchas)
|
|
81
|
+
- [Development](#development)
|
|
82
|
+
- [Live smoke test](#live-smoke-test)
|
|
83
|
+
- [Contributing](#contributing)
|
|
84
|
+
- [License](#license)
|
|
85
|
+
|
|
86
|
+
## Features
|
|
87
|
+
|
|
88
|
+
- **Login without a browser.** Cognito SRP runs over plain HTTP through the broker's own
|
|
89
|
+
`/apic/authc` proxy. No Chromium, no AWS WAF cookie, no API key.
|
|
90
|
+
- **Automatic sessions.** The first API call logs in; the 1-hour IdToken is refreshed from the
|
|
91
|
+
refresh-token cookie, and a rejected session is re-established once and retried.
|
|
92
|
+
- **Typed end to end.** Every response is a Pydantic model, account/position/order amounts are
|
|
93
|
+
`Decimal`, and models use `extra="forbid"` so broker schema drift fails loudly instead of
|
|
94
|
+
silently returning wrong data.
|
|
95
|
+
- **The broker's submit flow.** 2FA probe/unlock → `/verify` precheck → ex-ante cost document →
|
|
96
|
+
placement, plus order history and cancellation for market, limit, stop-limit, trailing-stop and
|
|
97
|
+
OCO orders.
|
|
98
|
+
- **App-push 2FA built in.** `place()` and `cancel()` unlock the order-execution session
|
|
99
|
+
automatically and wait for the approval on your paired device.
|
|
100
|
+
- **Offline, reproducible tests.** The suite replays sanitized, recorded broker payloads through
|
|
101
|
+
`httpx.MockTransport`. It never touches the network or a real account.
|
|
102
|
+
|
|
103
|
+
## What's implemented
|
|
104
|
+
|
|
105
|
+
| Status | Slice | What you get |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| ✅ | `client.account` | Cash account (Verrechnungskonto): buying/transfer power, IBAN, saldo, closing period. |
|
|
108
|
+
| ✅ | `client.instrument` | Typeahead search, master data (one model, typed `type`), market/fundamental detail, chart series. |
|
|
109
|
+
| ✅ | `client.portfolio` | Current depot positions with quantity, average entry quote and latest quote. |
|
|
110
|
+
| ✅ | `client.watchlist` | List, add and remove watchlist entries by ISIN. |
|
|
111
|
+
| ✅ | `client.orders` | List orders by status, place market/limit/stop-limit/trailing-stop/OCO orders, cancel them. |
|
|
112
|
+
| ✅ | `client.sessiontan` | Inspect, request and complete the app-push unlock for order execution. |
|
|
113
|
+
|
|
114
|
+
## Requirements
|
|
115
|
+
|
|
116
|
+
| Requirement | Why |
|
|
117
|
+
|---|---|
|
|
118
|
+
| **Python 3.13+** | The package targets 3.13 and is strictly typed throughout. |
|
|
119
|
+
| **A Finanzen.net ZERO account** | Email + password login is the only supported login method. There is no API key. |
|
|
120
|
+
| **The ZERO mobile app** | Required only for order execution: the 2FA push is approved on a paired device. Read-only calls work without it. |
|
|
121
|
+
| **Network access to `mein.finanzen-zero.net`** | That is where every endpoint lives. There is no sandbox or test environment. |
|
|
122
|
+
| **`httpx` and `pydantic`** | Installed automatically as the only runtime dependencies. |
|
|
123
|
+
|
|
124
|
+
**Not required:** a browser, Selenium/Playwright, a headless Chromium, an AWS WAF cookie, or an
|
|
125
|
+
API token. **Not supported:** asynchronous clients, multiple accounts in one client, or non-email
|
|
126
|
+
login methods (SSO/social).
|
|
127
|
+
|
|
128
|
+
This is a synchronous library built on `httpx.Client`. If your application is `asyncio`-based,
|
|
129
|
+
run calls in a worker thread.
|
|
130
|
+
|
|
131
|
+
## Installation
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install finanzen-zero-api
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
or, with [uv](https://docs.astral.sh/uv/):
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv add finanzen-zero-api
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Python **3.13+** is required. The PyPI distribution is named `finanzen-zero-api` (the name
|
|
144
|
+
`zero-api` was not available on PyPI); you import it as `zero_api`. `httpx` and `pydantic` are the
|
|
145
|
+
only runtime dependencies and are installed with the package. The wheel ships a
|
|
146
|
+
[`py.typed`](https://github.com/wtfuii/zero-api/blob/main/src/zero_api/py.typed) marker, so editors
|
|
147
|
+
and mypy see the types.
|
|
148
|
+
|
|
149
|
+
> **Alpha (0.1.0).** The API may still change between minor versions. Pin the version you depend on
|
|
150
|
+
> (`finanzen-zero-api==0.1.0`) until it stabilizes.
|
|
151
|
+
|
|
152
|
+
### From source
|
|
153
|
+
|
|
154
|
+
To work on the library itself, or to track unreleased changes:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
git clone https://github.com/wtfuii/zero-api.git
|
|
158
|
+
cd zero-api
|
|
159
|
+
uv sync
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Add that checkout to another project as an editable dependency:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
uv add --editable /path/to/zero-api
|
|
166
|
+
# or, with plain pip
|
|
167
|
+
python -m pip install -e /path/to/zero-api
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Inside the checkout, `uv run python your_script.py` uses the project environment.
|
|
171
|
+
|
|
172
|
+
## Quick start
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from zero_api import ZeroClient
|
|
176
|
+
|
|
177
|
+
client = ZeroClient(email="you@example.com", password="secret")
|
|
178
|
+
|
|
179
|
+
account = client.account.get()
|
|
180
|
+
print(f"Buying power: {account.buying_power:,.2f} EUR")
|
|
181
|
+
|
|
182
|
+
for position in client.portfolio.get_positions():
|
|
183
|
+
value = position.quote * position.quantity
|
|
184
|
+
print(f"{position.asset_name}: {position.quantity} x {position.quote} = {value}")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The constructor does **not** touch the network. The first API call performs the login; later calls
|
|
188
|
+
reuse the in-memory cookie session.
|
|
189
|
+
|
|
190
|
+
## Authentication and sessions
|
|
191
|
+
|
|
192
|
+
`ZeroClient(email, password)` keeps the credentials in memory and establishes the session lazily.
|
|
193
|
+
It has no credential-specific environment variables or config files; supply credentials (or a
|
|
194
|
+
`login` callback) explicitly and keep them out of source control.
|
|
195
|
+
|
|
196
|
+
The session lifecycle:
|
|
197
|
+
|
|
198
|
+
1. **First call:** full Cognito SRP login over HTTP (`USER_SRP_AUTH` + `PASSWORD_VERIFIER`).
|
|
199
|
+
2. **IdToken near expiry:** a cheap refresh-token exchange (falling back to a full login if the
|
|
200
|
+
refresh token is rejected); Cognito does not rotate the refresh token.
|
|
201
|
+
3. **`401`/`403`:** one refresh-or-login attempt, then the request is retried exactly once.
|
|
202
|
+
|
|
203
|
+
Sessions are **in memory only**. Cookies are never written to disk, so a new process logs in
|
|
204
|
+
again. Keep the `ZeroClient` alive if you want to avoid repeated logins in a long-running job.
|
|
205
|
+
|
|
206
|
+
The login flow is email + password only. The app push is used solely to unlock **order
|
|
207
|
+
execution**, not to sign in. Because SRP signs a UTC timestamp, keep the host clock synchronized
|
|
208
|
+
(NTP); large clock skew can make the login fail with `AuthenticationError`.
|
|
209
|
+
|
|
210
|
+
### Advanced: custom HTTP client or pre-existing session
|
|
211
|
+
|
|
212
|
+
`ZeroClient` accepts two escape hatches:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import httpx
|
|
216
|
+
|
|
217
|
+
from zero_api import ZeroClient
|
|
218
|
+
from zero_api.auth.models import AuthSession
|
|
219
|
+
|
|
220
|
+
# Your own transport: proxy, timeouts, connection limits.
|
|
221
|
+
http = httpx.Client(
|
|
222
|
+
base_url="https://mein.finanzen-zero.net", # required: request paths are relative
|
|
223
|
+
timeout=httpx.Timeout(30.0),
|
|
224
|
+
)
|
|
225
|
+
client = ZeroClient(email="you@example.com", password="secret", http=http)
|
|
226
|
+
|
|
227
|
+
# Supply sessions yourself (e.g. from a secret store) instead of logging in.
|
|
228
|
+
client = ZeroClient(
|
|
229
|
+
email="unused@example.com", # still required by the signature
|
|
230
|
+
password="unused",
|
|
231
|
+
login=lambda: AuthSession.from_cookies(
|
|
232
|
+
{"__i": "<IdToken>", "__r": "<RefreshToken>", "XSRF-TOKEN": "<uuid>"}
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
When `login` is set it takes precedence over the stored credentials for every (re)login, after
|
|
238
|
+
the refresh-token path is tried. `AuthSession.from_cookies()` requires the `__i` cookie and reads
|
|
239
|
+
the `customerId` from its `prm` claim; pass the rest of the session's cookies (`__r`,
|
|
240
|
+
`XSRF-TOKEN`) as well so refresh and state-changing calls keep working.
|
|
241
|
+
|
|
242
|
+
> **Lifecycle:** there is no `ZeroClient.close()` or `logout()`. The internally created
|
|
243
|
+
> `httpx.Client` lives for the process; dropping the client discards the in-memory session. If you
|
|
244
|
+
> need deterministic cleanup or connection pooling, pass your own client and close it yourself.
|
|
245
|
+
|
|
246
|
+
Low-level auth helpers (rarely needed):
|
|
247
|
+
|
|
248
|
+
| Function | Purpose |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `zero_api.auth.api.http_login(http, email, password)` | Full SRP login on a given `httpx.Client`. |
|
|
251
|
+
| `zero_api.auth.api.http_refresh(http, session)` | Refresh-token exchange without SRP. |
|
|
252
|
+
| `zero_api.auth.models.AuthSession.from_cookies(cookies)` | Build a session from a cookie jar. |
|
|
253
|
+
|
|
254
|
+
## API guide
|
|
255
|
+
|
|
256
|
+
All public methods hang off one `ZeroClient` instance. Methods are synchronous and return typed
|
|
257
|
+
Pydantic models. Account, position and order amounts are `Decimal`; market-data models such as
|
|
258
|
+
`InstrumentDetail` and `ChartBar` keep the wire's floats. Broker timestamps are timezone-aware
|
|
259
|
+
`datetime`s; display-date fields that the broker sends as text stay strings.
|
|
260
|
+
|
|
261
|
+
### Cash account
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
account = client.account.get()
|
|
265
|
+
print(account.buying_power, account.buying_power_without_credit)
|
|
266
|
+
print(account.transfer_power, account.account_iban, account.overdrawn)
|
|
267
|
+
print(account.next_closing_date)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`MoneyTransactionAccount` includes the account number, IBAN, intraday amount, buying/transfer
|
|
271
|
+
power (with and without credit), last imported saldo, debit interest and the next closing date.
|
|
272
|
+
If you need to exclude an available credit line, use `buying_power_without_credit` rather than
|
|
273
|
+
`buying_power`.
|
|
274
|
+
|
|
275
|
+
### Instruments
|
|
276
|
+
|
|
277
|
+
| Method | Returns | Notes |
|
|
278
|
+
|---|---|---|
|
|
279
|
+
| `search(query, *, size=10)` | `InstrumentSearchPage` | Free text: name, WKN (German security ID) or ISIN. |
|
|
280
|
+
| `get(isin)` | `Instrument` | Master record: `type` plus trading state, venues and fees. |
|
|
281
|
+
| `detail(isin)` | `InstrumentDetail` | Market data; one model with optional fields (the wire has no class discriminator). |
|
|
282
|
+
| `chart(isin, period=ChartPeriod.ONE_YEAR)` | `list[ChartBar]` | One series; internally fetches `detail()` first. |
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
from zero_api.instrument.models import ChartPeriod, InstrumentType
|
|
286
|
+
|
|
287
|
+
page = client.instrument.search("Rheinmetall", size=5)
|
|
288
|
+
print(page.stats.total) # every match, including those beyond the per-bucket cap
|
|
289
|
+
for asset_type, count in page.stats.hits.items():
|
|
290
|
+
print(asset_type.value, count)
|
|
291
|
+
for hit in page.results:
|
|
292
|
+
print(hit.isin, hit.wkn, hit.short_name, hit.type)
|
|
293
|
+
|
|
294
|
+
instrument = client.instrument.get("DE0007030009")
|
|
295
|
+
assert instrument.type is InstrumentType.STOCK
|
|
296
|
+
venue = next(v for v in instrument.trading_venues if v.default_venue)
|
|
297
|
+
print(instrument.short_name, instrument.tradable) # e.g. "OPEN"
|
|
298
|
+
print(venue.mic_code, venue.trading_hours_start, venue.trading_hours_stop)
|
|
299
|
+
print(instrument.min_qty, instrument.qty_incr, instrument.price_incr)
|
|
300
|
+
|
|
301
|
+
deriv = client.instrument.get("DE000UBS0JQ8")
|
|
302
|
+
if deriv.type is InstrumentType.DERIVATIVE:
|
|
303
|
+
print(deriv.underlying, deriv.option_type, deriv.disabled_exec_types)
|
|
304
|
+
|
|
305
|
+
bars = client.instrument.chart(instrument.isin, ChartPeriod.ONE_MONTH)
|
|
306
|
+
print(bars[-1].date, bars[-1].close)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Things worth knowing:
|
|
310
|
+
|
|
311
|
+
- `get()` always returns the single `Instrument` model. `instrument.type` is an `InstrumentType`
|
|
312
|
+
(`AKTIE`/`ETF`/`FONDS`/`DERIVAT`/`KRYPTO`); an ETC's *master* reports `ETF`, even though its
|
|
313
|
+
search hit says `ETC` (`InstrumentSearchType` is the six-value search-hit vocabulary). Fields
|
|
314
|
+
that can be null for some class are Optional: crypto `wkn`, ETF/fund `short_name`/`symbol`,
|
|
315
|
+
non-derivative `underlying`/`option_type`, fund/derivative/crypto `ranking`/`sentiment`.
|
|
316
|
+
- `InstrumentDetail.symbol` is actually the WKN, despite the field name; crypto details omit it.
|
|
317
|
+
- `mic_code` is the venue's ISO 10383 Market Identifier Code. Use a value from
|
|
318
|
+
`instrument.trading_venues`.
|
|
319
|
+
|
|
320
|
+
### Portfolio
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
positions = client.portfolio.get_positions()
|
|
324
|
+
for position in positions:
|
|
325
|
+
print(
|
|
326
|
+
position.isin,
|
|
327
|
+
position.quantity,
|
|
328
|
+
position.available_quantity,
|
|
329
|
+
position.avg_entry_quote,
|
|
330
|
+
position.quote,
|
|
331
|
+
)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`Position` carries the latest quote, the FIFO entry quote (`entry_quote_fifo`) and the edited
|
|
335
|
+
average entry quote. Quotes are informational; they are not a guaranteed executable price.
|
|
336
|
+
`Position` does not include a currency: the quote currency comes from the instrument master
|
|
337
|
+
(`Instrument.quote_currency_code`), so do not sum position values across instruments without
|
|
338
|
+
checking it.
|
|
339
|
+
|
|
340
|
+
> **Pagination:** `get_positions()` returns only the first page. No page/size parameters were
|
|
341
|
+
> ever observed, and `hasNext` was never seen `true`. If it ever becomes true for a very large
|
|
342
|
+
> depot, the extra positions are not fetched.
|
|
343
|
+
|
|
344
|
+
### Watchlist
|
|
345
|
+
|
|
346
|
+
| Method | Returns | Notes |
|
|
347
|
+
|---|---|---|
|
|
348
|
+
| `get()` | `list[WatchlistEntry]` | Instrument + watchlist metadata (order, added time, quotes). |
|
|
349
|
+
| `add_to_watchlist(isin)` | `WatchlistEntry` | Server prepends the entry with `wlOrder = 0`. |
|
|
350
|
+
| `remove_from_watchlist(isin)` | `None` | Keyed by ISIN, not by item id. |
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
client.watchlist.add_to_watchlist("DE0007030009")
|
|
354
|
+
for entry in client.watchlist.get():
|
|
355
|
+
print(entry.watchlist_item.wl_order, entry.instrument.short_name, entry.watchlist_item.bid)
|
|
356
|
+
client.watchlist.remove_from_watchlist("DE0007030009")
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Orders
|
|
360
|
+
|
|
361
|
+
| Method | Returns | Notes |
|
|
362
|
+
|---|---|---|
|
|
363
|
+
| `list(status="Open", *, page=0, size=1000)` | `OrdersPage` | `status` is the history tab: `"Open"`, `"Canceled"` or `"Filled"`. |
|
|
364
|
+
| `place(order, *, device=None)` | `Order` | Runs 2FA, verify, ex-ante document, then places the order. |
|
|
365
|
+
| `cancel(order_id, *, device=None)` | `Order` | Uses the string `order_id`, not the numeric `id`. |
|
|
366
|
+
|
|
367
|
+
`OrdersPage` has `.list` and `.has_next`. The default page size of 1000 covers most accounts; if
|
|
368
|
+
`has_next` is true, request the next page explicitly.
|
|
369
|
+
|
|
370
|
+
`Order.status` is an `OrderStatus` enum — compare it to enum members such as
|
|
371
|
+
`OrderStatus.FILLED`. The values you will normally see:
|
|
372
|
+
|
|
373
|
+
| Status | Meaning |
|
|
374
|
+
|---|---|
|
|
375
|
+
| `Created` | Accepted by the broker, not yet working at the venue |
|
|
376
|
+
| `Open` | Working at the venue |
|
|
377
|
+
| `Filled` / `PartiallyFilled_Filled` | Executed (the latter after a partial fill) |
|
|
378
|
+
| `Canceled` | Canceled |
|
|
379
|
+
| `Rejected` | Rejected by the broker or venue |
|
|
380
|
+
| `Expired` | Expired without executing |
|
|
381
|
+
| `Open_CreatedCancel`, … | Cancellation requested / in progress |
|
|
382
|
+
|
|
383
|
+
There are also `PartiallyFilled` variants with the same cancellation suffixes; `OrderStatus` lists
|
|
384
|
+
the full set.
|
|
385
|
+
|
|
386
|
+
#### Placing an order
|
|
387
|
+
|
|
388
|
+
> **This places a real order with real money.** There is no paper-trading or dry-run mode. The
|
|
389
|
+
> library does not validate lot sizes, price increments, trading hours or buying power — the
|
|
390
|
+
> broker does, and rejects what it does not like (usually as a `BrokerApiError`). Before building
|
|
391
|
+
> an order, check `instrument.tradable`, `instrument.disabled_exec_types`, `instrument.min_qty`,
|
|
392
|
+
> `instrument.qty_incr`, `instrument.price_incr`, `instrument.minimum_order_value` and the
|
|
393
|
+
> selected venue's trading hours.
|
|
394
|
+
|
|
395
|
+
Build an `OrderRequest` from the instrument's own trading rules:
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
from datetime import UTC, datetime, timedelta
|
|
399
|
+
from decimal import Decimal
|
|
400
|
+
|
|
401
|
+
from zero_api.ordering.models import ExecType, OrderRequest
|
|
402
|
+
|
|
403
|
+
instrument = client.instrument.get("DE0007030009")
|
|
404
|
+
venue = next(v for v in instrument.trading_venues if v.default_venue)
|
|
405
|
+
|
|
406
|
+
order = OrderRequest(
|
|
407
|
+
isin=instrument.isin,
|
|
408
|
+
direction="buy", # "buy" or "sell"
|
|
409
|
+
exec_type=ExecType.LIMIT,
|
|
410
|
+
quantity=Decimal("1"), # respect instrument.min_qty / qty_incr
|
|
411
|
+
mic_code=venue.mic_code, # target venue, from trading_venues
|
|
412
|
+
limit_price=Decimal("900.00"), # example only; use instrument.price_incr steps
|
|
413
|
+
valid_to_date=datetime.now(UTC) + timedelta(days=30),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
placed = client.orders.place(order) # approve the 2FA push on your device
|
|
417
|
+
print(placed.order_id, placed.status) # "Created" — not filled yet
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Supported `ExecType` values and their price fields:
|
|
421
|
+
|
|
422
|
+
| `ExecType` | Placeable | Fields |
|
|
423
|
+
|---|---|---|
|
|
424
|
+
| `MARKET` | ✅ | `quantity`, `mic_code` |
|
|
425
|
+
| `LIMIT` | ✅ | `limit_price` |
|
|
426
|
+
| `STOP_LIMIT` | ✅ | `limit_price`, `stop_price` |
|
|
427
|
+
| `TSL` | ✅ | `stop_price` plus `trailing_price_pct` or `trailing_price_abs` |
|
|
428
|
+
| `OCO` | ✅ | `limit_price`, `stop_price` |
|
|
429
|
+
| `stop` (plain stop-loss) | ❌ | history only |
|
|
430
|
+
| `savings`, `fraction`, `previously_quoted` | ❌ | history only |
|
|
431
|
+
|
|
432
|
+
`TSL` is a trailing stop-loss; `OCO` is one-cancels-the-other.
|
|
433
|
+
|
|
434
|
+
`valid_to_date=None` means "now" (what the app sends for market orders). Naive datetimes are
|
|
435
|
+
treated as UTC. `quantity` and all prices are serialized as JSON numbers because that is what the
|
|
436
|
+
broker accepts, even though you pass `Decimal`s. Prices are in the instrument's quote currency
|
|
437
|
+
(`instrument.quote_currency_code`), not necessarily EUR.
|
|
438
|
+
|
|
439
|
+
`place()` performs the site's full submit sequence:
|
|
440
|
+
|
|
441
|
+
1. Probes the 2FA unlock and unlocks first if needed (see below).
|
|
442
|
+
2. `POST /api/trading/order/verify` — precheck.
|
|
443
|
+
3. `POST /apic/exante` — generates the mandatory ex-ante cost document in the broker's
|
|
444
|
+
Posteingang (inbox). **This is a real side effect**, even if the order is rejected afterwards.
|
|
445
|
+
4. `POST /api/trading/order` — the actual order.
|
|
446
|
+
|
|
447
|
+
It returns as soon as the broker accepts the order (`status="Created"`). It does **not** wait for
|
|
448
|
+
a fill; poll the order lists for that:
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
for page_status in ("Open", "Filled", "Canceled"):
|
|
452
|
+
page = client.orders.list(page_status)
|
|
453
|
+
for order in page.list:
|
|
454
|
+
print(page_status, order.order_id, order.status, order.executed_quantity, order.exec_price)
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Market orders are only accepted during the chosen venue's trading session. Outside those hours,
|
|
458
|
+
the broker answers `406` with
|
|
459
|
+
`errorCode="ORDER_TYPE_NOT_ALLOWED_CHEAP_ORDER_OUTSIDE_SESSION_WINDOW"`. That is a broker error,
|
|
460
|
+
not a client bug.
|
|
461
|
+
|
|
462
|
+
#### Cancelling an order
|
|
463
|
+
|
|
464
|
+
```python
|
|
465
|
+
page = client.orders.list("Open")
|
|
466
|
+
for order in page.list:
|
|
467
|
+
print(order.order_id, order.isin, order.status, order.quantity)
|
|
468
|
+
|
|
469
|
+
# client.orders.cancel(order.order_id) # also needs the 2FA session unlocked
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`cancel()` returns the order in a transition state (for example `"Open_CreatedCancel"`); the final
|
|
473
|
+
state is not immediate. Poll the order lists until the order leaves `Open`.
|
|
474
|
+
|
|
475
|
+
### 2FA order unlock
|
|
476
|
+
|
|
477
|
+
Order execution is protected by an app push. The unlock is **server-side session state** tied to
|
|
478
|
+
the cookie session — no extra token is stored by the client.
|
|
479
|
+
|
|
480
|
+
You normally do not call this slice yourself: `orders.place()` and `orders.cancel()` probe
|
|
481
|
+
`is_authorized()` and unlock automatically when needed. Approve the push in the ZERO app; the call
|
|
482
|
+
blocks until then (60 seconds by default, polling every 2 seconds).
|
|
483
|
+
|
|
484
|
+
```python
|
|
485
|
+
if not client.sessiontan.is_authorized():
|
|
486
|
+
devices = client.sessiontan.enrolled_devices()
|
|
487
|
+
print([(d.serial, d.customer_device_name) for d in devices])
|
|
488
|
+
client.sessiontan.unlock(devices[0], timeout=120)
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**Multiple enrolled devices:** the client refuses to guess. Set a default once — in the
|
|
492
|
+
constructor (`ZeroClient(..., default_device="<serial>")`) or at runtime — or pass a device to each
|
|
493
|
+
call:
|
|
494
|
+
|
|
495
|
+
```python
|
|
496
|
+
client.sessiontan.default_device = devices[0] # str serial or EnrolledDevice
|
|
497
|
+
client.orders.place(order, device=devices[0])
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
`unlock()` raises `TwoFactorError` on timeout, on a declined/unknown state, or when several
|
|
501
|
+
devices are enrolled and none was chosen. Only `AUTHORIZATION_INITIAL` (pending) and
|
|
502
|
+
`AUTHORIZATION_USER_ACCEPTED` (approved) have been observed; any other state fails loudly rather
|
|
503
|
+
than being guessed. If you need a longer wait than `place()`'s built-in 60 seconds, pre-unlock
|
|
504
|
+
with `client.sessiontan.unlock(..., timeout=...)` and then place the order.
|
|
505
|
+
|
|
506
|
+
| Method | Purpose |
|
|
507
|
+
|---|---|
|
|
508
|
+
| `is_authorized()` | Is this session unlocked for order execution? |
|
|
509
|
+
| `enrolled_devices()` | Devices that can receive the push. |
|
|
510
|
+
| `unlock(device=None, *, timeout=60.0, poll_interval=2.0)` | Full flow: choose device, push, poll, verify. |
|
|
511
|
+
| `request_auth(serial)` / `auth_state(handle)` / `abort(handle)` | Low-level primitives behind the flow; normally use `unlock()`. |
|
|
512
|
+
|
|
513
|
+
The unlock is bound to the cookie session and has been observed to persist for hours, including
|
|
514
|
+
across browser restarts on the same cookies. A fresh login may require a new approval.
|
|
515
|
+
|
|
516
|
+
## Error handling
|
|
517
|
+
|
|
518
|
+
All library-specific exceptions derive from `ZeroApiError`:
|
|
519
|
+
|
|
520
|
+
| Exception | Raised when | Useful attributes |
|
|
521
|
+
|---|---|---|
|
|
522
|
+
| `ZeroApiError` | Base class for the library's own errors. | — |
|
|
523
|
+
| `AuthenticationError` | Login/refresh failed, or no login method is configured. | — |
|
|
524
|
+
| `TwoFactorError` | The 2FA flow could not be completed: timeout, unknown/declined state, ambiguous device, or an unexpected 2FA response. | — |
|
|
525
|
+
| `BrokerApiError` | Non-2xx response with the broker's `{"type": "apiError", ...}` body. | `status`, `message`, `error_code`, `errors` |
|
|
526
|
+
|
|
527
|
+
```python
|
|
528
|
+
from zero_api.core.exceptions import BrokerApiError, TwoFactorError, ZeroApiError
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
client.orders.place(order)
|
|
532
|
+
except BrokerApiError as exc:
|
|
533
|
+
print(exc.status, exc.error_code, exc.message, exc.errors)
|
|
534
|
+
except TwoFactorError as exc:
|
|
535
|
+
print("2FA not completed:", exc)
|
|
536
|
+
except ZeroApiError as exc:
|
|
537
|
+
print("zero-api error:", exc)
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Other failures are intentionally **not** wrapped:
|
|
541
|
+
|
|
542
|
+
- `pydantic.ValidationError` — the broker's response no longer matches the recorded schema. This
|
|
543
|
+
is the loud drift signal; upgrade the library or open an issue with the endpoint and error.
|
|
544
|
+
- `httpx.HTTPStatusError` — a non-2xx response that is not the broker's `apiError` shape.
|
|
545
|
+
- `httpx.HTTPError` — network, timeout and transport errors.
|
|
546
|
+
- `ValueError` — an unexpected success body where the client expected an explicit confirmation
|
|
547
|
+
(for example a watchlist removal that did not answer `true`).
|
|
548
|
+
|
|
549
|
+
## Project status
|
|
550
|
+
|
|
551
|
+
**Alpha (0.1.0).**
|
|
552
|
+
|
|
553
|
+
Known unverified corners, deliberately left out:
|
|
554
|
+
|
|
555
|
+
- Some execution types appear only in order history (`savings`, `fraction`, `stop`,
|
|
556
|
+
`previously_quoted`); the client types them but cannot place them.
|
|
557
|
+
- Declined/expired 2FA states were never captured; unknown values raise `TwoFactorError`.
|
|
558
|
+
- Position pagination has never been observed to kick in.
|
|
559
|
+
|
|
560
|
+
## Limitations and gotchas
|
|
561
|
+
|
|
562
|
+
- **Private endpoints, no contract.** The broker can change a payload silently. The strict
|
|
563
|
+
Pydantic models are designed to turn that into a visible error, not to make it impossible.
|
|
564
|
+
- **No retries beyond one re-auth.** Rate limiting, backoff and idempotency are your
|
|
565
|
+
responsibility. In particular, do not blindly retry `place()` after a timeout — the order may
|
|
566
|
+
already exist. Check the order lists first.
|
|
567
|
+
- **Sessions do not survive a process.** Cookies live only in memory; persist nothing unless you
|
|
568
|
+
build that yourself and protect it accordingly.
|
|
569
|
+
- **No async API.** Run the synchronous calls in a thread if you need them inside an event loop.
|
|
570
|
+
- **No `close()` or `logout()`.** Pass your own `httpx.Client` if you need explicit lifetime
|
|
571
|
+
management; discard the client to drop the session.
|
|
572
|
+
- **`place()` is blocking and side-effecting.** It can wait up to 60 seconds for a 2FA approval
|
|
573
|
+
and creates an ex-ante cost document before submitting the order.
|
|
574
|
+
- **The library won't save you from a bad order.** It forwards what you build. Validate quantity
|
|
575
|
+
increments, price ticks, market hours and buying power against `Instrument` and
|
|
576
|
+
`MoneyTransactionAccount` first.
|
|
577
|
+
- **No sandbox.** Every authenticated call goes to the real broker.
|
|
578
|
+
|
|
579
|
+
## Development
|
|
580
|
+
|
|
581
|
+
```bash
|
|
582
|
+
uv sync
|
|
583
|
+
uv run mypy . # strict, over src/ and tests/
|
|
584
|
+
uv run pytest # offline suite, no broker access
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Run a single test:
|
|
588
|
+
|
|
589
|
+
```bash
|
|
590
|
+
uv run pytest tests/ordering/test_ordering.py -k place
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
The suite replays **real, sanitized broker payloads** from `tests/<slice>/fixtures/` through
|
|
594
|
+
`httpx.MockTransport`. Do not replace them with hand-written approximations, and never add a test
|
|
595
|
+
that calls the live broker.
|
|
596
|
+
|
|
597
|
+
The release process is documented in
|
|
598
|
+
[RELEASING.md](https://github.com/wtfuii/zero-api/blob/main/RELEASING.md).
|
|
599
|
+
|
|
600
|
+
Project layout:
|
|
601
|
+
|
|
602
|
+
```text
|
|
603
|
+
src/zero_api/
|
|
604
|
+
├── client.py # ZeroClient — the client object
|
|
605
|
+
├── core/ # shared HTTP session, headers, exceptions
|
|
606
|
+
├── auth/ # Cognito SRP login + token refresh
|
|
607
|
+
├── account/ # cash account
|
|
608
|
+
├── instrument/ # search, master data, detail, charts
|
|
609
|
+
├── portfolio/ # depot positions
|
|
610
|
+
├── watchlist/ # watchlist read/add/remove
|
|
611
|
+
├── ordering/ # orders: list, place, cancel
|
|
612
|
+
└── sessiontan/ # app-push 2FA unlock
|
|
613
|
+
tests/ # offline tests + sanitized fixtures, mirrors the slices
|
|
614
|
+
scripts/ # developer tools (live smoke test)
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
## Live smoke test
|
|
618
|
+
|
|
619
|
+
[live_smoke.py](https://github.com/wtfuii/zero-api/blob/main/scripts/live_smoke.py) is a developer
|
|
620
|
+
tool that talks to the real broker. It is **not** part of the test suite.
|
|
621
|
+
|
|
622
|
+
```bash
|
|
623
|
+
export ZERO_EMAIL="you@example.com"
|
|
624
|
+
export ZERO_PASSWORD="..." # or let the script prompt
|
|
625
|
+
uv run python scripts/live_smoke.py --skip-mutations
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
Read-only checks cover login/account, instrument search/get/detail/chart, positions, watchlist
|
|
629
|
+
reads and order lists. By default the script also does a watchlist add/remove round-trip and
|
|
630
|
+
restores the list; `--skip-mutations` disables that.
|
|
631
|
+
|
|
632
|
+
`--trade-isin <ISIN>` opts into a **real-money** round-trip: one market buy, wait for the fill,
|
|
633
|
+
one market sell. Fees apply to both orders. The script asks for confirmation and refuses to sell
|
|
634
|
+
unless the buy is confirmed filled. Use `--help` for timeouts, device selection and MIC options.
|
|
635
|
+
|
|
636
|
+
## Contributing
|
|
637
|
+
|
|
638
|
+
Issues and pull requests are welcome. Before opening one:
|
|
639
|
+
|
|
640
|
+
1. Run `uv run mypy .` and `uv run pytest`.
|
|
641
|
+
2. Add or update the sanitized fixture in the slice's `tests/<slice>/fixtures/` and test against
|
|
642
|
+
it.
|
|
643
|
+
3. Keep the vertical-slice structure: a slice owns its transport, models and client methods;
|
|
644
|
+
shared HTTP/session code belongs in `core/`. Do not add a browser dependency to `src/`.
|
|
645
|
+
|
|
646
|
+
Never paste cookies, IdTokens, account numbers, device serials or raw HAR files into an issue or
|
|
647
|
+
commit. `captures/` and `docs/explorations/captures/` are gitignored for that reason.
|
|
648
|
+
|
|
649
|
+
## License
|
|
650
|
+
|
|
651
|
+
[MIT](https://github.com/wtfuii/zero-api/blob/main/LICENSE) © 2026 Klaus Deja.
|
|
652
|
+
|
|
653
|
+
This project is not affiliated with, endorsed by, or supported by Finanzen.net or Finanzen.net
|
|
654
|
+
ZERO. Automating a broker account may violate its terms of service; you are responsible for how
|
|
655
|
+
you use this library. Trading involves risk, and software bugs can cause financial loss.
|