ctrader-api-client 0.11.0__tar.gz → 0.12.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.
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/PKG-INFO +10 -3
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/README.md +9 -2
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/events.md +29 -2
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/models.md +5 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/symbols.md +28 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/getting-started.md +2 -1
- ctrader_api_client-0.12.0/justfile +88 -0
- ctrader_api_client-0.12.0/plans/nested-event-models.md +187 -0
- ctrader_api_client-0.12.0/plans/symbol-swaps.md +209 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/pyproject.toml +1 -1
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/__init__.py +6 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/trading.py +1 -1
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/client.py +3 -2
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/enums.py +28 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/_execution.py +11 -34
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/router.py +4 -6
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/types.py +15 -22
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/__init__.py +2 -0
- ctrader_api_client-0.12.0/src/ctrader_api_client/models/swap.py +109 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/symbol.py +5 -7
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_trading.py +21 -13
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/events/test_routing.py +97 -34
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_enum_mapping.py +10 -0
- ctrader_api_client-0.12.0/tests/unit/test_swap_conversion.py +155 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_symbol_conversion.py +0 -6
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/uv.lock +1 -1
- ctrader_api_client-0.11.0/Justfile +0 -56
- ctrader_api_client-0.11.0/plans/startup-token-refresh.md +0 -157
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.claude/settings.local.json +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.complexipy_cache/.gitignore +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.complexipy_cache/CACHEDIR.TAG +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.complexipy_cache/README.md +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.complexipy_cache/v/cache/functions +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.github/workflows/docs.yml +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.gitignore +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.pre-commit-config.yaml +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.python-version +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/LICENSE +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/accounts.md +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/client.md +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/enums.md +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/exceptions.md +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/market-data.md +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/trading.md +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/index.md +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/mkdocs.yml +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/SOURCE +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/VERSION +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/update.sh +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiMessages.proto +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/scripts/fix_proto_imports.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/backoff.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/clock.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/messages.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/_base.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/accounts.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/market_data.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/symbols.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/_maintain.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/_session.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/credentials.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/manager.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/policy.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/store.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/composition.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/config.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/protocol.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/supervisor.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/transport.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/emitter.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/publisher.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/exceptions.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/_base.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/account.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/commission.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/deal.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/market_data.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/order.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/position.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/requests.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/trading_hours.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/py.typed +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/conftest.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_market_data.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_reference_data.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_subscription_restore.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/conftest.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/test_authentication.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/test_token_lifecycle.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/conftest.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_framing.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_heartbeat.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_reconnection.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_request_response.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/events/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/events/conftest.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/events/test_subscriptions.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/test_client.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/conftest.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/clock.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/factories.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/recorder.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/server.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/signals.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/stub_protocol.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/wire.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/__init__.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_api_errors.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_commission_conversion.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_market_data_conversion.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_request_conversion.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_trading_hours.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_trading_records.py +0 -0
- {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_wire_format.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrader-api-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: API Client to interact with the cTrader Open API spec
|
|
5
5
|
Author-email: Elio <elioachukri@pm.me>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -252,6 +252,7 @@ await client.trading.close_position(account_id, close_position)
|
|
|
252
252
|
```python
|
|
253
253
|
from ctrader_api_client import (
|
|
254
254
|
ExecutionEvent,
|
|
255
|
+
ExecutionType,
|
|
255
256
|
ReadyEvent,
|
|
256
257
|
ReconnectedEvent,
|
|
257
258
|
SpotEvent,
|
|
@@ -264,10 +265,16 @@ from ctrader_api_client import (
|
|
|
264
265
|
async def on_spot(event: SpotEvent):
|
|
265
266
|
print(f"{event.bid}/{event.ask}")
|
|
266
267
|
|
|
267
|
-
# Order executions
|
|
268
|
+
# Order executions. The event carries the Order, Position and Deal models
|
|
269
|
+
# that the trading API returns. Each one is None when the server sent none.
|
|
268
270
|
@client.on(ExecutionEvent, account_id=account_id)
|
|
269
271
|
async def on_execution(event: ExecutionEvent):
|
|
270
|
-
|
|
272
|
+
if event.order is None:
|
|
273
|
+
return # A swap or a deposit has no order.
|
|
274
|
+
print(f"Order {event.order.order_id}: {event.execution_type}")
|
|
275
|
+
if event.execution_type is ExecutionType.ORDER_FILLED and event.order.is_closing_order:
|
|
276
|
+
# The order type says why the position closed.
|
|
277
|
+
print(f"Closed by {event.order.order_type.name}, stop-out={event.order.is_stop_out}")
|
|
271
278
|
|
|
272
279
|
# Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
|
|
273
280
|
# Subscriptions are already restored by this point; use it to reconcile your own state.
|
|
@@ -233,6 +233,7 @@ await client.trading.close_position(account_id, close_position)
|
|
|
233
233
|
```python
|
|
234
234
|
from ctrader_api_client import (
|
|
235
235
|
ExecutionEvent,
|
|
236
|
+
ExecutionType,
|
|
236
237
|
ReadyEvent,
|
|
237
238
|
ReconnectedEvent,
|
|
238
239
|
SpotEvent,
|
|
@@ -245,10 +246,16 @@ from ctrader_api_client import (
|
|
|
245
246
|
async def on_spot(event: SpotEvent):
|
|
246
247
|
print(f"{event.bid}/{event.ask}")
|
|
247
248
|
|
|
248
|
-
# Order executions
|
|
249
|
+
# Order executions. The event carries the Order, Position and Deal models
|
|
250
|
+
# that the trading API returns. Each one is None when the server sent none.
|
|
249
251
|
@client.on(ExecutionEvent, account_id=account_id)
|
|
250
252
|
async def on_execution(event: ExecutionEvent):
|
|
251
|
-
|
|
253
|
+
if event.order is None:
|
|
254
|
+
return # A swap or a deposit has no order.
|
|
255
|
+
print(f"Order {event.order.order_id}: {event.execution_type}")
|
|
256
|
+
if event.execution_type is ExecutionType.ORDER_FILLED and event.order.is_closing_order:
|
|
257
|
+
# The order type says why the position closed.
|
|
258
|
+
print(f"Closed by {event.order.order_type.name}, stop-out={event.order.is_stop_out}")
|
|
252
259
|
|
|
253
260
|
# Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
|
|
254
261
|
# Subscriptions are already restored by this point; use it to reconcile your own state.
|
|
@@ -14,7 +14,7 @@ async def on_spot(event: SpotEvent):
|
|
|
14
14
|
|
|
15
15
|
@client.on(ExecutionEvent, account_id=12345) # Filter by account
|
|
16
16
|
async def on_execution(event: ExecutionEvent):
|
|
17
|
-
print(f"Order {event.order_id}: {event.execution_type}")
|
|
17
|
+
print(f"Order {event.order.order_id}: {event.execution_type}")
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
### Filtering
|
|
@@ -24,7 +24,7 @@ Different events support different filters:
|
|
|
24
24
|
| Event | `account_id` | `symbol_id` |
|
|
25
25
|
|-------|--------------|-------------|
|
|
26
26
|
| SpotEvent | Yes | Yes |
|
|
27
|
-
| ExecutionEvent | Yes |
|
|
27
|
+
| ExecutionEvent | Yes | No |
|
|
28
28
|
| DepthEvent | Yes | Yes |
|
|
29
29
|
| ReadyEvent | Yes | No |
|
|
30
30
|
| OrderErrorEvent | Yes | No |
|
|
@@ -72,6 +72,30 @@ async def on_spot(event: SpotEvent):
|
|
|
72
72
|
|
|
73
73
|
## Trading Events
|
|
74
74
|
|
|
75
|
+
An execution event carries the `Order`, `Position` and `Deal` models that the
|
|
76
|
+
trading API returns. Each one is `None` when the server sent no such message.
|
|
77
|
+
A swap or a deposit has no order. An accepted order has no position and no deal
|
|
78
|
+
yet.
|
|
79
|
+
|
|
80
|
+
The order says why a position closed:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from ctrader_api_client import ExecutionEvent, ExecutionType, OrderType
|
|
84
|
+
|
|
85
|
+
@client.on(ExecutionEvent, account_id=12345)
|
|
86
|
+
async def on_execution(event: ExecutionEvent):
|
|
87
|
+
if event.execution_type is not ExecutionType.ORDER_FILLED or event.order is None:
|
|
88
|
+
return
|
|
89
|
+
if not event.order.is_closing_order:
|
|
90
|
+
return # An entry fill.
|
|
91
|
+
if event.order.is_stop_out:
|
|
92
|
+
print("Closed by a margin stop-out")
|
|
93
|
+
elif event.order.order_type is OrderType.STOP_LOSS_TAKE_PROFIT:
|
|
94
|
+
print("Closed by a stop loss or a take profit")
|
|
95
|
+
else:
|
|
96
|
+
print("Closed by a market order")
|
|
97
|
+
```
|
|
98
|
+
|
|
75
99
|
::: ctrader_api_client.events.ExecutionEvent
|
|
76
100
|
options:
|
|
77
101
|
show_source: false
|
|
@@ -113,6 +137,9 @@ suppressed for `TOKEN_REFRESH` (session intact).
|
|
|
113
137
|
show_source: false
|
|
114
138
|
members: true
|
|
115
139
|
|
|
140
|
+
A trader update carries the `Account` model that the accounts API returns.
|
|
141
|
+
Its balance is a `Decimal` in account currency units.
|
|
142
|
+
|
|
116
143
|
::: ctrader_api_client.events.TraderUpdateEvent
|
|
117
144
|
options:
|
|
118
145
|
show_source: false
|
|
@@ -190,6 +190,11 @@ symbol's quote currency, or in the currency that `minimum_asset` names. A `minim
|
|
|
190
190
|
means the broker sets no minimum.
|
|
191
191
|
|
|
192
192
|
|
|
193
|
+
::: ctrader_api_client.models.Swap
|
|
194
|
+
options:
|
|
195
|
+
show_source: false
|
|
196
|
+
|
|
197
|
+
|
|
193
198
|
::: ctrader_api_client.models.TradingHours
|
|
194
199
|
options:
|
|
195
200
|
show_source: false
|
|
@@ -87,6 +87,34 @@ charge lands in USD for the first two types, and in the symbol's quote currency
|
|
|
87
87
|
[Models](models.md#ctrader_api_client.models.Commission) for the arithmetic of each type,
|
|
88
88
|
and for how to apply `minimum`.
|
|
89
89
|
|
|
90
|
+
## Swap
|
|
91
|
+
|
|
92
|
+
A broker states a swap as two rates and a type. The type says what the rates count, so
|
|
93
|
+
you must read them together. The same rate of `-1.5` is pips per lot for one symbol, and
|
|
94
|
+
an annual percent for another. A negative rate is a charge.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from ctrader_api_client import SwapCalculationType
|
|
98
|
+
|
|
99
|
+
symbol = await client.symbols.get_by_id(account_id, 270)
|
|
100
|
+
swap = symbol.swap
|
|
101
|
+
|
|
102
|
+
print(f"{swap.long} / {swap.short} ({swap.calculation_type.value})")
|
|
103
|
+
print(f"Tripled on {swap.triple_day.value}")
|
|
104
|
+
print(f"Charged every {swap.period_hours} hours from {swap.charge_time} UTC")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Type | Meaning of the rates |
|
|
108
|
+
|------|----------------------|
|
|
109
|
+
| `PIPS` | Pips per lot, per charge period. |
|
|
110
|
+
| `PERCENTAGE` | Annual percent of the position value. |
|
|
111
|
+
| `POINTS` | Points per lot, per charge period. |
|
|
112
|
+
|
|
113
|
+
The broker charges the swap once per `period_hours`, starting at `charge_time` UTC. On
|
|
114
|
+
`triple_day` the charge is three times the rate, to cover the weekend. `period_hours` is
|
|
115
|
+
`None` when the broker does not publish it. See
|
|
116
|
+
[Models](models.md#ctrader_api_client.models.Swap) for every field.
|
|
117
|
+
|
|
90
118
|
## Trading Hours
|
|
91
119
|
|
|
92
120
|
Every `Symbol` arrives with its trading calendar attached, so asking when a symbol is open
|
|
@@ -146,7 +146,8 @@ async def on_price(event: SpotEvent):
|
|
|
146
146
|
@client.on(ExecutionEvent)
|
|
147
147
|
async def on_execution(event: ExecutionEvent):
|
|
148
148
|
"""Called when orders are executed."""
|
|
149
|
-
|
|
149
|
+
if event.order is not None:
|
|
150
|
+
print(f"Execution: {event.execution_type} for order {event.order.order_id}")
|
|
150
151
|
|
|
151
152
|
|
|
152
153
|
async def main():
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# cTrader API client justfile
|
|
2
|
+
set shell := ["bash", "-euo", "pipefail", "-c"]
|
|
3
|
+
set script-interpreter := ["bash", "-euo", "pipefail"]
|
|
4
|
+
set positional-arguments
|
|
5
|
+
|
|
6
|
+
# Variables
|
|
7
|
+
proto_path := "protos/vendor"
|
|
8
|
+
python_out_path := "src/ctrader_api_client/_internal/proto"
|
|
9
|
+
|
|
10
|
+
# Default recipe. List all available recipes.
|
|
11
|
+
[private]
|
|
12
|
+
default:
|
|
13
|
+
@just --list
|
|
14
|
+
|
|
15
|
+
# Verify that the required toolchains are present
|
|
16
|
+
[group('build')]
|
|
17
|
+
[script]
|
|
18
|
+
check-deps:
|
|
19
|
+
if ! command -v uv &> /dev/null; then
|
|
20
|
+
echo "uv not found. Install from https://docs.astral.sh/uv/getting-started/installation/" >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
echo "==> All required toolchains are present"
|
|
24
|
+
|
|
25
|
+
# Install the pre-commit hooks, then build the project. Extra arguments go to `uv sync`.
|
|
26
|
+
[group('build')]
|
|
27
|
+
[script]
|
|
28
|
+
setup *args: check-deps
|
|
29
|
+
echo "==> Install the pre-commit hooks"
|
|
30
|
+
uv tool install prek
|
|
31
|
+
uv tool run prek install --hook-type pre-commit
|
|
32
|
+
echo "==> Build the project"
|
|
33
|
+
just build "$@"
|
|
34
|
+
|
|
35
|
+
# Sync the Python dependencies. Extra arguments go to `uv sync`.
|
|
36
|
+
[group('build')]
|
|
37
|
+
build *args:
|
|
38
|
+
uv sync "$@"
|
|
39
|
+
|
|
40
|
+
# Format the code. Extra arguments go to `ruff format`.
|
|
41
|
+
[group('dev')]
|
|
42
|
+
fmt *args:
|
|
43
|
+
uv run ruff format "$@"
|
|
44
|
+
|
|
45
|
+
# Lint the code. Extra arguments go to `ruff check`.
|
|
46
|
+
[group('dev')]
|
|
47
|
+
lint *args:
|
|
48
|
+
uv run ruff check --fix "$@"
|
|
49
|
+
|
|
50
|
+
# Type-check the code. Extra arguments go to `zuban check`.
|
|
51
|
+
[group('dev')]
|
|
52
|
+
type-check *args:
|
|
53
|
+
uv run zuban check "$@"
|
|
54
|
+
|
|
55
|
+
# Run the tests. Extra arguments go to pytest.
|
|
56
|
+
[group('dev')]
|
|
57
|
+
test *args:
|
|
58
|
+
uv run pytest "$@"
|
|
59
|
+
|
|
60
|
+
# Run the whole CI pipeline
|
|
61
|
+
[group('dev')]
|
|
62
|
+
@ci:
|
|
63
|
+
just fmt
|
|
64
|
+
just lint
|
|
65
|
+
just type-check
|
|
66
|
+
just test
|
|
67
|
+
|
|
68
|
+
# Serve the documentation locally. Extra arguments go to `mkdocs serve`.
|
|
69
|
+
[group('tools')]
|
|
70
|
+
documentation *args:
|
|
71
|
+
uv run mkdocs serve "$@"
|
|
72
|
+
|
|
73
|
+
# Fetch the .proto files at the given version into the vendor directory
|
|
74
|
+
[group('tools')]
|
|
75
|
+
update-proto version='main':
|
|
76
|
+
./protos/update.sh "$1"
|
|
77
|
+
|
|
78
|
+
# Generate the Python code from the .proto files. Run this after `update-proto`.
|
|
79
|
+
[group('tools')]
|
|
80
|
+
[script]
|
|
81
|
+
compile-proto:
|
|
82
|
+
echo "==> Compile the .proto files from {{ proto_path }}/ into {{ python_out_path }}/"
|
|
83
|
+
uv run protoc -I={{ proto_path }} --python_betterproto_out={{ python_out_path }} {{ proto_path }}/*.proto
|
|
84
|
+
echo "==> Fix the cross-module imports"
|
|
85
|
+
uv run python scripts/fix_proto_imports.py
|
|
86
|
+
echo "==> Format the generated code"
|
|
87
|
+
uv run ruff check --fix {{ python_out_path }}
|
|
88
|
+
uv run ruff format {{ python_out_path }}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Nested event models
|
|
2
|
+
|
|
3
|
+
Status: steps 1 to 3 done on 2026-10-21. The version bump to 0.12.0 waits for another change that ships with it.
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
A consumer cannot tell why a position closed from the live event stream.
|
|
8
|
+
|
|
9
|
+
- A close arrives as a `ProtoOAExecutionEvent` of type `ORDER_FILLED`. Its
|
|
10
|
+
embedded `ProtoOAOrder` says why: `order_type` is `STOP_LOSS_TAKE_PROFIT` for
|
|
11
|
+
a protection close and `MARKET` for a manual close, `closing_order` marks a
|
|
12
|
+
close, and `is_stop_out` marks a margin close.
|
|
13
|
+
- `execution_event_from_proto` keeps only the side, the order id and the symbol
|
|
14
|
+
id from the order. It keeps only the filled volume, the price and the
|
|
15
|
+
timestamp from the deal. It keeps only the position id from the position.
|
|
16
|
+
- The only public path to the order type is `TradingAPI.get_orders`, which is a
|
|
17
|
+
second round trip that races the next event.
|
|
18
|
+
|
|
19
|
+
The same shape repeats in `TraderUpdateEvent`. The server sends a full
|
|
20
|
+
`ProtoOATrader`, and the router keeps three of its thirty-two fields.
|
|
21
|
+
|
|
22
|
+
The library already has a faithful model for each embedded message: `Order`,
|
|
23
|
+
`Position`, `Deal` and `Account`. The events translate the same data a second
|
|
24
|
+
time, and translate less of it.
|
|
25
|
+
|
|
26
|
+
## Facts from the source
|
|
27
|
+
|
|
28
|
+
- `events/_execution.py`: `execution_event_from_proto` reads presence of the
|
|
29
|
+
order, the position and the deal with `betterproto.serialized_on_wire`. It
|
|
30
|
+
falls back to `datetime.now(UTC)` for the timestamp when the event has no
|
|
31
|
+
deal. Both `TradingAPI` and `EventRouter` call it.
|
|
32
|
+
- `models/order.py`, `models/position.py`: `from_proto` tolerates an absent
|
|
33
|
+
`trade_data`. Both require a known `order_type`, `order_status` or
|
|
34
|
+
`position_status`; an unset enum raises `UnknownEnumValueError`.
|
|
35
|
+
- `models/deal.py`: `from_proto` requires a known `trade_side` and
|
|
36
|
+
`deal_status`. It reads the close detail only when its balance is positive.
|
|
37
|
+
- `models/account.py`: `Account.from_proto` scales `balance` by
|
|
38
|
+
`money_divisor(money_digits)`, which reads 0 as cents. `TraderUpdateEvent`
|
|
39
|
+
carries the raw integer plus `money_digits` instead, so a live balance and a
|
|
40
|
+
fetched balance are in different units.
|
|
41
|
+
- `events/router.py`: `_handle_trader_update` drops the event when the trader
|
|
42
|
+
is absent. `_handle_execution` logs `event.order_id`.
|
|
43
|
+
- `events/emitter.py`: `_validate_filter` checks `__dataclass_fields__` for
|
|
44
|
+
`account_id` and `symbol_id`. `_matches_filter` reads them with `getattr`.
|
|
45
|
+
- `client.py`: `T_BothFilters` names `SpotEvent`, `ExecutionEvent` and
|
|
46
|
+
`DepthEvent` as the events that accept a `symbol_id` filter. No test pins the
|
|
47
|
+
symbol filter on `ExecutionEvent`.
|
|
48
|
+
- Tests that construct or read the two events: `tests/behavior/events/test_routing.py`,
|
|
49
|
+
`tests/behavior/api/test_trading.py`, `tests/unit/test_enum_mapping.py`. The
|
|
50
|
+
routing tests build orders without an order type or status, and deals without
|
|
51
|
+
a side or status.
|
|
52
|
+
- Docs that show `event.order_id`: `README.md`, `docs/getting-started.md`,
|
|
53
|
+
`docs/api/events.md`, and the docstrings of `Client.on` and `TradingAPI`.
|
|
54
|
+
|
|
55
|
+
## Design
|
|
56
|
+
|
|
57
|
+
1. `ExecutionEvent` becomes:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
account_id: int
|
|
61
|
+
execution_type: ExecutionType
|
|
62
|
+
order: Order | None
|
|
63
|
+
position: Position | None
|
|
64
|
+
deal: Deal | None
|
|
65
|
+
is_server_event: bool = False
|
|
66
|
+
error_code: str | None = None
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Each nested field is `None` when the sub-message was not on the wire. The
|
|
70
|
+
converter calls the three existing `from_proto` methods. `order_id`,
|
|
71
|
+
`position_id`, `symbol_id`, `side`, `filled_volume`, `fill_price` and
|
|
72
|
+
`timestamp` leave. The execution timestamp lives on `deal.execution_timestamp`,
|
|
73
|
+
and an event with no deal has no timestamp instead of a fabricated one.
|
|
74
|
+
|
|
75
|
+
2. `TraderUpdateEvent` becomes:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
account_id: int
|
|
79
|
+
account: Account
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The router already drops an event with no trader, so `account` is not
|
|
83
|
+
optional. `balance`, `leverage_in_cents` and `money_digits` leave. The
|
|
84
|
+
balance is a scaled `Decimal` from the one converter that owns the scaling.
|
|
85
|
+
|
|
86
|
+
3. `ExecutionEvent` loses the `symbol_id` filter. The symbol lives on the nested
|
|
87
|
+
order, and a swap or a deposit event has no order at all. `T_BothFilters`
|
|
88
|
+
drops `ExecutionEvent`, and the filter table in `docs/api/events.md` says
|
|
89
|
+
`No`. A consumer that wants one symbol reads `event.order.symbol_id` in the
|
|
90
|
+
handler.
|
|
91
|
+
|
|
92
|
+
4. The reason for a close reads from one event:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
if event.execution_type is ExecutionType.ORDER_FILLED and event.order.is_closing_order:
|
|
96
|
+
... # event.order.order_type, event.order.is_stop_out
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### What does not change
|
|
100
|
+
|
|
101
|
+
- An execution type the client does not know still raises from the converter.
|
|
102
|
+
The API layer lets it reach the caller, and the router drops the event. A
|
|
103
|
+
nested message with an unknown enum now takes the same path, because the
|
|
104
|
+
model converters raise the same error.
|
|
105
|
+
- `account_id` stays on both events. It is the routing key that the account
|
|
106
|
+
filter reads, and every event in `events/types.py` carries it.
|
|
107
|
+
- `MarginCallTriggerEvent` keeps its flat shape. There is no `MarginCall` model,
|
|
108
|
+
and no consumer asks for the fields the event drops.
|
|
109
|
+
- `DepthEvent` keeps `DepthQuote` in `events/types.py`. No other message uses it.
|
|
110
|
+
- `bonus_deposit_withdraw` and `deposit_withdraw` stay unread. There is no
|
|
111
|
+
model for them and no consumer asks for them.
|
|
112
|
+
- The `Order`, `Position`, `Deal` and `Account` models do not change.
|
|
113
|
+
|
|
114
|
+
### Attribute counts
|
|
115
|
+
|
|
116
|
+
- `ExecutionEvent`: 10 today, 7 after.
|
|
117
|
+
- `TraderUpdateEvent`: 4 today, 2 after.
|
|
118
|
+
|
|
119
|
+
## Steps
|
|
120
|
+
|
|
121
|
+
One step per turn. Each step leaves the suite green.
|
|
122
|
+
|
|
123
|
+
### Step 1: `ExecutionEvent` nests `Order`, `Position` and `Deal`
|
|
124
|
+
|
|
125
|
+
- `events/types.py`: the new field list. The docstring states that each nested
|
|
126
|
+
field is `None` when the server sent no such message.
|
|
127
|
+
- `events/_execution.py`: call `Order.from_proto`, `Position.from_proto` and
|
|
128
|
+
`Deal.from_proto` behind the existing presence checks. Remove the timestamp
|
|
129
|
+
fallback and the `Decimal` and `order_side` imports.
|
|
130
|
+
- `events/router.py`: the debug line reads the order id through
|
|
131
|
+
`event.order`, and logs `None` when there is no order.
|
|
132
|
+
- `client.py`: `T_BothFilters` drops `ExecutionEvent`. The `Client.on`
|
|
133
|
+
docstring example reads `event.order.order_id`.
|
|
134
|
+
- `api/trading.py`: the class docstring example reads `execution.order.order_id`.
|
|
135
|
+
- `docs/api/events.md`: the filter table marks `ExecutionEvent` as `No` for
|
|
136
|
+
`symbol_id`. The code example reads `event.order.order_id`.
|
|
137
|
+
- `tests/behavior/events/test_routing.py`: the `order()` helper carries an
|
|
138
|
+
order type and a status. Deals carry a side and a status. Positions carry a
|
|
139
|
+
status. The assertions read through `event.order`, `event.position` and
|
|
140
|
+
`event.deal`. Replace the four tests on the flat fields with:
|
|
141
|
+
1. A fill carries its order, position and deal: `event.order.order_id`,
|
|
142
|
+
`event.position.position_id`, `event.deal.filled_volume`,
|
|
143
|
+
`event.deal.execution_price` and `event.deal.execution_timestamp`.
|
|
144
|
+
2. A sell execution reports the sell side through `event.order.side`.
|
|
145
|
+
3. An accepted order has no position and no deal: both are `None`.
|
|
146
|
+
4. A swap has no order: `event.order is None`.
|
|
147
|
+
5. A close reports its reason: an `ORDER_FILLED` event whose order has
|
|
148
|
+
`order_type=STOP_LOSS_TAKE_PROFIT` and `closing_order=True` yields
|
|
149
|
+
`event.order.is_closing_order` and `event.order.order_type is
|
|
150
|
+
OrderType.STOP_LOSS_TAKE_PROFIT`.
|
|
151
|
+
- `tests/behavior/api/test_trading.py`: the two tests that read the flat
|
|
152
|
+
fields of a placed order read through the nested models instead.
|
|
153
|
+
- `tests/unit/test_enum_mapping.py`: `test_an_unknown_execution_type_is_an_error`
|
|
154
|
+
passes unchanged.
|
|
155
|
+
|
|
156
|
+
### Step 2: `TraderUpdateEvent` nests `Account`
|
|
157
|
+
|
|
158
|
+
- `events/types.py`: the new field list.
|
|
159
|
+
- `events/router.py`: `_handle_trader_update` builds `Account.from_proto(trader)`.
|
|
160
|
+
Remove the `DEFAULT_MONEY_DIGITS` use from this handler only; the margin
|
|
161
|
+
handler keeps it.
|
|
162
|
+
- `tests/behavior/events/test_routing.py`: replace the two trader update tests
|
|
163
|
+
with:
|
|
164
|
+
1. A trader update carries the account: `event.account.balance ==
|
|
165
|
+
Decimal("10000.00")` for a wire balance of `1_000_000` at two money
|
|
166
|
+
digits, and `event.account.leverage_in_cents == 10_000`.
|
|
167
|
+
2. A trader update without money digits scales as cents: a wire balance of
|
|
168
|
+
`1_000_000` with no `money_digits` yields `Decimal("10000.00")`.
|
|
169
|
+
|
|
170
|
+
### Step 3: docs and version
|
|
171
|
+
|
|
172
|
+
- `README.md` and `docs/getting-started.md`: the execution handler example
|
|
173
|
+
reads `event.order.order_id`. Add a short example that reads the reason for a
|
|
174
|
+
close from `event.order`.
|
|
175
|
+
- `docs/api/events.md`: the `ExecutionEvent` section states that the three
|
|
176
|
+
nested models are the same types that `TradingAPI` returns.
|
|
177
|
+
- `pyproject.toml`: 0.12.0. Deferred: another change ships in the same bump.
|
|
178
|
+
|
|
179
|
+
## Consumer follow-up
|
|
180
|
+
|
|
181
|
+
Out of scope for this repository. Recorded so the two changes stay aligned.
|
|
182
|
+
|
|
183
|
+
Each consumer bumps its pin to 0.12.0 and reads `event.order`, `event.position`
|
|
184
|
+
and `event.deal` instead of the flat fields. A handler on `ExecutionEvent` that
|
|
185
|
+
filtered by `symbol_id` moves the check into the handler body. A handler on
|
|
186
|
+
`TraderUpdateEvent` reads `event.account.balance` as a `Decimal` in account
|
|
187
|
+
currency units and drops its own scaling.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Symbol swaps
|
|
2
|
+
|
|
3
|
+
Status: complete on 2026-10-21. Ships in the 0.12.0 bump with the nested
|
|
4
|
+
event models.
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
A consumer cannot compute what a position costs to hold overnight.
|
|
9
|
+
|
|
10
|
+
- `Symbol` keeps `swap_long` and `swap_short` as bare decimals. The server sends
|
|
11
|
+
them with `swapCalculationType`, which says whether a rate is pips per lot,
|
|
12
|
+
an annual percentage, or points. A rate read without its type is meaningless.
|
|
13
|
+
The commission module makes the same argument for the commission rate, and
|
|
14
|
+
keeps the rate with its type. The swap rate does not get that treatment.
|
|
15
|
+
- `Symbol.from_proto` drops `swapRollover3Days`, `swapPeriod`, `swapTime`,
|
|
16
|
+
`skipSWAPPeriods` and `chargeSwapAtWeekends`. A consumer that wants to know
|
|
17
|
+
when a swap is charged, and on which day it is tripled, has no path to it.
|
|
18
|
+
|
|
19
|
+
## Facts from the source
|
|
20
|
+
|
|
21
|
+
- `models/commission.py`: `Commission.from_proto(proto: ProtoOASymbol)` reads a
|
|
22
|
+
group of related fields out of the symbol proto into one sub-model. `Symbol`
|
|
23
|
+
holds it as `commission: Commission = Field(default_factory=Commission)`.
|
|
24
|
+
This is the pattern to copy.
|
|
25
|
+
- `_internal/proto/OpenApiModelMessages.py`: `ProtoOASwapCalculationType` has
|
|
26
|
+
`PIPS = 0`, `PERCENTAGE = 1`, `POINTS = 2`. The vendor docs name only the
|
|
27
|
+
first two. `ProtoOADayOfWeek` has `NONE = 0` through `SUNDAY = 7`. The
|
|
28
|
+
generated name for `skipSWAPPeriods` is `skip_s_w_a_p_periods`.
|
|
29
|
+
- `protos/vendor/OpenApiModelMessages.proto`: `swapRollover3Days` and
|
|
30
|
+
`rolloverCommission3Days` declare `[default = MONDAY]`. `swapCalculationType`
|
|
31
|
+
declares `[default = PIPS]`. `swapPeriod`, `swapTime`, `skipSWAPPeriods` and
|
|
32
|
+
`chargeSwapAtWeekends` declare no default. The generated code applies no
|
|
33
|
+
proto2 default, so each of these reads as 0 when the broker omits it.
|
|
34
|
+
- `_internal/conversions.py`: `enum_member(mapping, value, unset=...)` reads 0
|
|
35
|
+
as the declared default when the caller passes one, and raises on 0 when the
|
|
36
|
+
caller passes none.
|
|
37
|
+
- `tests/unit/test_symbol_conversion.py`: `forex_symbol()` builds a `Symbol`
|
|
38
|
+
with `swap_long` and `swap_short`. `tests/unit/test_enum_mapping.py` lists
|
|
39
|
+
each optional enum with its declared default in `OPTIONAL`.
|
|
40
|
+
- `tests/unit/test_commission_conversion.py`: two tests pin values a live
|
|
41
|
+
broker sent, with the raw wire value and the expected reading in the
|
|
42
|
+
docstring. This is how a playground finding becomes a test.
|
|
43
|
+
- The playground script outside the repository holds the live credentials and
|
|
44
|
+
an editable install of this package. `client.protocol.request(...)` returns
|
|
45
|
+
raw protos. The account exposes 202 symbols.
|
|
46
|
+
|
|
47
|
+
## Design
|
|
48
|
+
|
|
49
|
+
1. A `Swap` sub-model in `models/swap.py`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
class Swap(FrozenModel):
|
|
53
|
+
calculation_type: SwapCalculationType = SwapCalculationType.PIPS
|
|
54
|
+
long: Decimal = Decimal(0)
|
|
55
|
+
short: Decimal = Decimal(0)
|
|
56
|
+
triple_day: DayOfWeek = DayOfWeek.MONDAY
|
|
57
|
+
period_hours: int | None = None
|
|
58
|
+
charge_time: time = time(0)
|
|
59
|
+
skip_periods: int = 0
|
|
60
|
+
charged_at_weekends: bool = False
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`Swap.from_proto(proto: ProtoOASymbol)` reads the eight fields. `Symbol`
|
|
64
|
+
gains `swap: Swap = Field(default_factory=Swap)`, and `swap_long` and
|
|
65
|
+
`swap_short` leave `Symbol`. The two rates stay as the server sends them.
|
|
66
|
+
The type says what they count, the same as the commission rate.
|
|
67
|
+
|
|
68
|
+
2. Two enums in `enums.py`. `SwapCalculationType` has `PIPS`, `PERCENTAGE` and
|
|
69
|
+
`POINTS`. `DayOfWeek` has `NONE` and the seven days. The proto maps live in
|
|
70
|
+
`models/swap.py`, the same as the commission maps.
|
|
71
|
+
|
|
72
|
+
3. How each unset value reads:
|
|
73
|
+
- `calculation_type`: 0 is `PIPS` on the wire and is also the declared
|
|
74
|
+
default, so there is nothing to decide.
|
|
75
|
+
- `triple_day`: 0 is `NONE` on the wire, and the declared default is
|
|
76
|
+
`MONDAY`. The wire cannot tell the two apart. Every other enum with a
|
|
77
|
+
declared default reads 0 as that default, so this one does too. Step 3
|
|
78
|
+
checks against the live broker whether this reading loses a real `NONE`.
|
|
79
|
+
- `period_hours`: 0 means the broker said nothing. The model reports `None`.
|
|
80
|
+
It does not guess 24, because the library never guesses at a value the
|
|
81
|
+
broker did not send.
|
|
82
|
+
- `charge_time`: 0 is midnight UTC, which is both the literal reading and
|
|
83
|
+
the sensible default. The model converts minutes from midnight to a
|
|
84
|
+
`datetime.time`.
|
|
85
|
+
- `skip_periods` and `charged_at_weekends`: 0 and `False` are the literal
|
|
86
|
+
readings and need no default.
|
|
87
|
+
|
|
88
|
+
4. The playground check comes after the implementation, not before. The model
|
|
89
|
+
gives the check something to compare the raw protos against, and a finding
|
|
90
|
+
that contradicts the design changes the design in a later step.
|
|
91
|
+
|
|
92
|
+
### What does not change
|
|
93
|
+
|
|
94
|
+
- The rollover fields stay unread: `rolloverCommission`, `skipRolloverDays`
|
|
95
|
+
and `rolloverCommission3Days`. They are the administrative fee that replaces
|
|
96
|
+
swaps on a swap-free account. The docs give no scale for the amount, and no
|
|
97
|
+
consumer account is swap-free. Step 3 records whether the live broker sends
|
|
98
|
+
them, so a later plan has the facts.
|
|
99
|
+
- `Account.swap_free` stays where it is.
|
|
100
|
+
- `Commission`, `TradingHours` and the rest of `Symbol` do not change.
|
|
101
|
+
- `distanceSetIn`, `gslDistance` and `gslCharge` stay out of scope. The stop
|
|
102
|
+
distances have the same shape of problem as the swap rate, a number without
|
|
103
|
+
its unit, and get their own plan.
|
|
104
|
+
|
|
105
|
+
### Attribute counts
|
|
106
|
+
|
|
107
|
+
- `Symbol`: 20 today, 19 after. `swap_long` and `swap_short` leave, `swap`
|
|
108
|
+
arrives.
|
|
109
|
+
- `Swap`: 8.
|
|
110
|
+
|
|
111
|
+
## Steps
|
|
112
|
+
|
|
113
|
+
One step per turn. Each step leaves the suite green.
|
|
114
|
+
|
|
115
|
+
### Step 1: the `Swap` model
|
|
116
|
+
|
|
117
|
+
- `enums.py`: add `SwapCalculationType` and `DayOfWeek`.
|
|
118
|
+
- `models/swap.py`: the `Swap` model, the two proto maps, and `from_proto`.
|
|
119
|
+
The module docstring states why the rate travels with its type, in the
|
|
120
|
+
same words the commission module uses.
|
|
121
|
+
- `models/symbol.py`: `swap: Swap = Field(default_factory=Swap)` replaces the
|
|
122
|
+
two rates. `from_proto` calls `Swap.from_proto(proto)`. The class docstring
|
|
123
|
+
describes `swap` and drops the two rate lines.
|
|
124
|
+
- `models/__init__.py` and `__init__.py`: export `Swap`, `SwapCalculationType`
|
|
125
|
+
and `DayOfWeek`.
|
|
126
|
+
- `tests/unit/test_swap_conversion.py`, new:
|
|
127
|
+
1. Each calculation type is translated, including `POINTS`.
|
|
128
|
+
2. A calculation type the broker omits reads as `PIPS`.
|
|
129
|
+
3. The long and short rates keep their decimal value.
|
|
130
|
+
4. A triple day the broker omits reads as `MONDAY`.
|
|
131
|
+
5. Each named day is translated.
|
|
132
|
+
6. A swap period the broker omits reads as `None`, and a period of 8 reads
|
|
133
|
+
as 8.
|
|
134
|
+
7. A charge time of 90 minutes reads as 01:30 UTC, and an omitted one reads
|
|
135
|
+
as midnight.
|
|
136
|
+
8. The skip count and the weekend flag are carried over.
|
|
137
|
+
9. A symbol without any swap field reports the default `Swap`.
|
|
138
|
+
- `tests/unit/test_symbol_conversion.py`: `forex_symbol()` drops the two rate
|
|
139
|
+
settings. Nothing in the file reads them.
|
|
140
|
+
- `tests/unit/test_enum_mapping.py`: `OPTIONAL` gains `swap_calculation_type`
|
|
141
|
+
with default `PIPS` and `swap_rollover3_days` with default `MONDAY`.
|
|
142
|
+
|
|
143
|
+
### Step 2: docs
|
|
144
|
+
|
|
145
|
+
- `docs/api/symbols.md`: a `## Swap` section after `## Commission`. It states
|
|
146
|
+
that the rate and the type are read together, gives a table of the three
|
|
147
|
+
types, and shows `symbol.swap.long`, `symbol.swap.triple_day` and
|
|
148
|
+
`symbol.swap.period_hours`.
|
|
149
|
+
- `docs/api/models.md`: `::: ctrader_api_client.models.Swap` after
|
|
150
|
+
`Commission`.
|
|
151
|
+
|
|
152
|
+
### Step 3: check the live broker
|
|
153
|
+
|
|
154
|
+
A script in the scratchpad directory imports the credentials from the
|
|
155
|
+
playground. It never copies them. It authenticates, requests
|
|
156
|
+
`ProtoOASymbolByIdReq` for a forex pair, a metal, an index and a crypto
|
|
157
|
+
symbol, and prints for each one:
|
|
158
|
+
|
|
159
|
+
- the raw wire values of the eight swap fields and the three rollover fields,
|
|
160
|
+
- the converted `Swap`.
|
|
161
|
+
|
|
162
|
+
The questions it answers, recorded in this file under `## Findings`:
|
|
163
|
+
|
|
164
|
+
1. Does any symbol send `swapRollover3Days` as 0? If so, the `MONDAY` reading
|
|
165
|
+
is wrong for that broker, and the next step decides between a `NONE`
|
|
166
|
+
reading and a documented limit.
|
|
167
|
+
2. What does `swapCalculationType` carry, and does any symbol send `POINTS`?
|
|
168
|
+
3. What do `swapPeriod`, `swapTime` and `skipSWAPPeriods` carry? Does any
|
|
169
|
+
symbol omit `swapPeriod`?
|
|
170
|
+
4. Does the broker send the three rollover fields at all, and at what scale?
|
|
171
|
+
|
|
172
|
+
### Step 4: pin the findings
|
|
173
|
+
|
|
174
|
+
- For each finding that fixes a reading, a test in
|
|
175
|
+
`test_swap_conversion.py` with the raw wire value and the expected reading
|
|
176
|
+
in its docstring, the same as the two live commission tests.
|
|
177
|
+
- If finding 1 shows a real `NONE`, this plan gains a step that changes the
|
|
178
|
+
`triple_day` reading, and the version bump waits for it.
|
|
179
|
+
|
|
180
|
+
## Findings
|
|
181
|
+
|
|
182
|
+
Checked on 2026-10-21 against the demo broker in the playground, over all 202
|
|
183
|
+
symbols the account exposes. The scratchpad script reads the playground
|
|
184
|
+
constants with `ast` so that it never runs the playground or copies a secret.
|
|
185
|
+
|
|
186
|
+
1. **No symbol sends `swapRollover3Days` as 0.** Every symbol sends
|
|
187
|
+
`WEDNESDAY` (87, every forex pair and every metal) or `FRIDAY` (115, every
|
|
188
|
+
index, commodity, crypto and equity). The `MONDAY` reading of 0 is never
|
|
189
|
+
exercised by this broker, so it stays as the declared default. `DayOfWeek`
|
|
190
|
+
keeps no `NONE` member.
|
|
191
|
+
2. **`swapCalculationType` is `PIPS` for 171 symbols and `PERCENTAGE` for
|
|
192
|
+
31.** No symbol sends `POINTS`. Every `PERCENTAGE` symbol is a crypto pair
|
|
193
|
+
and sends `-30.0` for both rates: an annual charge of 30 percent either
|
|
194
|
+
way. A `PIPS` example is EURUSD at `-1.084` long and `0.051` short.
|
|
195
|
+
3. **`swapPeriod` is 24, `swapTime` is 1259 and `skipSWAPPeriods` is 0 on
|
|
196
|
+
every symbol.** The broker charges once a day at 20:59 UTC. No symbol omits
|
|
197
|
+
the period, so the `None` reading is never exercised here. One symbol,
|
|
198
|
+
BRK.B, sets `chargeSwapAtWeekends`.
|
|
199
|
+
4. **The broker sends the rollover schedule but no rollover fee.**
|
|
200
|
+
`rolloverCommission` is 0, `skipRolloverDays` is 3 and
|
|
201
|
+
`rolloverCommission3Days` is `WEDNESDAY` on every symbol. The account is
|
|
202
|
+
not swap-free, so the fee amount and its scale stay unknown. The rollover
|
|
203
|
+
fields stay out of the model.
|
|
204
|
+
|
|
205
|
+
Also seen: 34 exotic forex pairs send 0 for both rates with `PIPS`, which the
|
|
206
|
+
model reports as a zero swap, which is what the broker says.
|
|
207
|
+
|
|
208
|
+
No finding contradicts the design. Step 4 pins findings 2 and 3 as tests with
|
|
209
|
+
the raw wire values.
|