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.
Files changed (31) hide show
  1. finanzen_zero_api-0.1.0/LICENSE +21 -0
  2. finanzen_zero_api-0.1.0/PKG-INFO +655 -0
  3. finanzen_zero_api-0.1.0/README.md +628 -0
  4. finanzen_zero_api-0.1.0/pyproject.toml +56 -0
  5. finanzen_zero_api-0.1.0/src/zero_api/__init__.py +5 -0
  6. finanzen_zero_api-0.1.0/src/zero_api/account/__init__.py +1 -0
  7. finanzen_zero_api-0.1.0/src/zero_api/account/api.py +14 -0
  8. finanzen_zero_api-0.1.0/src/zero_api/account/models.py +41 -0
  9. finanzen_zero_api-0.1.0/src/zero_api/auth/__init__.py +1 -0
  10. finanzen_zero_api-0.1.0/src/zero_api/auth/api.py +133 -0
  11. finanzen_zero_api-0.1.0/src/zero_api/auth/models.py +74 -0
  12. finanzen_zero_api-0.1.0/src/zero_api/auth/srp.py +137 -0
  13. finanzen_zero_api-0.1.0/src/zero_api/client.py +45 -0
  14. finanzen_zero_api-0.1.0/src/zero_api/core/__init__.py +1 -0
  15. finanzen_zero_api-0.1.0/src/zero_api/core/exceptions.py +27 -0
  16. finanzen_zero_api-0.1.0/src/zero_api/core/http.py +216 -0
  17. finanzen_zero_api-0.1.0/src/zero_api/instrument/__init__.py +5 -0
  18. finanzen_zero_api-0.1.0/src/zero_api/instrument/api.py +59 -0
  19. finanzen_zero_api-0.1.0/src/zero_api/instrument/models.py +330 -0
  20. finanzen_zero_api-0.1.0/src/zero_api/ordering/__init__.py +0 -0
  21. finanzen_zero_api-0.1.0/src/zero_api/ordering/api.py +92 -0
  22. finanzen_zero_api-0.1.0/src/zero_api/ordering/models.py +213 -0
  23. finanzen_zero_api-0.1.0/src/zero_api/portfolio/__init__.py +5 -0
  24. finanzen_zero_api-0.1.0/src/zero_api/portfolio/api.py +22 -0
  25. finanzen_zero_api-0.1.0/src/zero_api/portfolio/models.py +71 -0
  26. finanzen_zero_api-0.1.0/src/zero_api/py.typed +0 -0
  27. finanzen_zero_api-0.1.0/src/zero_api/sessiontan/api.py +109 -0
  28. finanzen_zero_api-0.1.0/src/zero_api/sessiontan/models.py +29 -0
  29. finanzen_zero_api-0.1.0/src/zero_api/watchlist/__init__.py +5 -0
  30. finanzen_zero_api-0.1.0/src/zero_api/watchlist/api.py +33 -0
  31. 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.