ctrader-api-client 0.1.0__py3-none-any.whl

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 (43) hide show
  1. ctrader_api_client/__init__.py +64 -0
  2. ctrader_api_client/_internal/__init__.py +26 -0
  3. ctrader_api_client/_internal/messages.py +348 -0
  4. ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +42 -0
  5. ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +30 -0
  6. ctrader_api_client/_internal/proto/OpenApiMessages.py +1112 -0
  7. ctrader_api_client/_internal/proto/OpenApiModelMessages.py +802 -0
  8. ctrader_api_client/_internal/proto/__init__.py +320 -0
  9. ctrader_api_client/_internal/serialization.py +84 -0
  10. ctrader_api_client/api/__init__.py +21 -0
  11. ctrader_api_client/api/accounts.py +71 -0
  12. ctrader_api_client/api/market_data.py +424 -0
  13. ctrader_api_client/api/symbols.py +171 -0
  14. ctrader_api_client/api/trading.py +506 -0
  15. ctrader_api_client/auth/__init__.py +14 -0
  16. ctrader_api_client/auth/credentials.py +72 -0
  17. ctrader_api_client/auth/manager.py +511 -0
  18. ctrader_api_client/client.py +475 -0
  19. ctrader_api_client/config.py +56 -0
  20. ctrader_api_client/connection/__init__.py +16 -0
  21. ctrader_api_client/connection/heartbeat.py +120 -0
  22. ctrader_api_client/connection/protocol.py +366 -0
  23. ctrader_api_client/connection/transport.py +123 -0
  24. ctrader_api_client/enums.py +138 -0
  25. ctrader_api_client/events/__init__.py +65 -0
  26. ctrader_api_client/events/emitter.py +254 -0
  27. ctrader_api_client/events/router.py +400 -0
  28. ctrader_api_client/events/types.py +340 -0
  29. ctrader_api_client/exceptions.py +231 -0
  30. ctrader_api_client/models/__init__.py +50 -0
  31. ctrader_api_client/models/_base.py +19 -0
  32. ctrader_api_client/models/account.py +177 -0
  33. ctrader_api_client/models/deal.py +242 -0
  34. ctrader_api_client/models/market_data.py +192 -0
  35. ctrader_api_client/models/order.py +262 -0
  36. ctrader_api_client/models/position.py +209 -0
  37. ctrader_api_client/models/requests.py +299 -0
  38. ctrader_api_client/models/symbol.py +194 -0
  39. ctrader_api_client/py.typed +0 -0
  40. ctrader_api_client-0.1.0.dist-info/METADATA +252 -0
  41. ctrader_api_client-0.1.0.dist-info/RECORD +43 -0
  42. ctrader_api_client-0.1.0.dist-info/WHEEL +4 -0
  43. ctrader_api_client-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from decimal import Decimal
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._internal.proto import ProtoOAAccessRights, ProtoOAAccountType
8
+ from ..enums import AccessRights, AccountType
9
+ from ._base import FrozenModel
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from .._internal.proto import ProtoOACtidTraderAccount, ProtoOATrader
14
+
15
+
16
+ def _timestamp_to_datetime(timestamp_ms: int) -> datetime:
17
+ """Convert millisecond timestamp to datetime."""
18
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)
19
+
20
+
21
+ _ACCOUNT_TYPE_MAP: dict[int, AccountType] = {
22
+ ProtoOAAccountType.HEDGED: AccountType.HEDGED,
23
+ ProtoOAAccountType.NETTED: AccountType.NETTED,
24
+ ProtoOAAccountType.SPREAD_BETTING: AccountType.SPREAD_BETTING,
25
+ }
26
+
27
+ _ACCESS_RIGHTS_MAP: dict[int, AccessRights] = {
28
+ ProtoOAAccessRights.FULL_ACCESS: AccessRights.FULL_ACCESS,
29
+ ProtoOAAccessRights.CLOSE_ONLY: AccessRights.CLOSE_ONLY,
30
+ ProtoOAAccessRights.NO_TRADING: AccessRights.NO_TRADING,
31
+ ProtoOAAccessRights.NO_LOGIN: AccessRights.NO_LOGIN,
32
+ }
33
+
34
+
35
+ class AccountSummary(FrozenModel):
36
+ """Summary of a trading account from account list.
37
+
38
+ This is the lightweight representation returned when listing accounts
39
+ associated with an access token. Use Account for full details after
40
+ authorization.
41
+
42
+ Attributes:
43
+ account_id: The cTID trader account ID.
44
+ is_live: True if this is a live account, False for demo.
45
+ trader_login: The trader's login number.
46
+ broker_name: Short name of the broker.
47
+ last_closing_deal_timestamp: Timestamp of last closing deal, or None.
48
+ last_balance_update_timestamp: Timestamp of last balance update, or None.
49
+ """
50
+
51
+ account_id: int
52
+ is_live: bool
53
+ trader_login: int
54
+ broker_name: str
55
+ last_closing_deal_timestamp: datetime | None = None
56
+ last_balance_update_timestamp: datetime | None = None
57
+
58
+ @classmethod
59
+ def from_proto(cls, proto: ProtoOACtidTraderAccount) -> AccountSummary:
60
+ """Create AccountSummary from proto message.
61
+
62
+ Args:
63
+ proto: The proto message to convert.
64
+
65
+ Returns:
66
+ A new AccountSummary instance.
67
+ """
68
+ return cls(
69
+ account_id=proto.ctid_trader_account_id,
70
+ is_live=proto.is_live,
71
+ trader_login=proto.trader_login,
72
+ broker_name=proto.broker_title_short or "",
73
+ last_closing_deal_timestamp=(
74
+ _timestamp_to_datetime(proto.last_closing_deal_timestamp) if proto.last_closing_deal_timestamp else None
75
+ ),
76
+ last_balance_update_timestamp=(
77
+ _timestamp_to_datetime(proto.last_balance_update_timestamp)
78
+ if proto.last_balance_update_timestamp
79
+ else None
80
+ ),
81
+ )
82
+
83
+
84
+ class Account(FrozenModel):
85
+ """Full trading account details.
86
+
87
+ Retrieved after authorizing a specific account. Contains balance,
88
+ leverage, and other trading parameters.
89
+
90
+ Attributes:
91
+ account_id: The cTID trader account ID.
92
+ trader_login: The trader's login number.
93
+ balance: Account balance (raw integer, use get_balance() for Decimal).
94
+ money_digits: Decimal places for monetary values.
95
+ leverage_in_cents: Account leverage in cents (e.g., 10000 = 1:100).
96
+ account_type: Account type (HEDGED, NETTED, SPREAD_BETTING).
97
+ access_rights: Current access rights for the account.
98
+ broker_name: Name of the broker.
99
+ deposit_asset_id: Asset ID of the deposit currency.
100
+ swap_free: Whether account is swap-free (Islamic Banking).
101
+ is_limited_risk: Whether account has limited risk mode.
102
+ registration_timestamp: When the account was registered.
103
+ max_leverage: Maximum allowed leverage.
104
+ balance_version: Version number for balance updates.
105
+ manager_bonus: Manager bonus amount (raw integer).
106
+ ib_bonus: IB bonus amount (raw integer).
107
+ non_withdrawable_bonus: Non-withdrawable bonus amount (raw integer).
108
+ """
109
+
110
+ account_id: int
111
+ trader_login: int
112
+ balance: int
113
+ money_digits: int
114
+ leverage_in_cents: int
115
+ account_type: AccountType
116
+ access_rights: AccessRights
117
+ broker_name: str
118
+ deposit_asset_id: int
119
+ swap_free: bool
120
+ is_limited_risk: bool
121
+ registration_timestamp: datetime | None = None
122
+
123
+ # Optional fields
124
+ max_leverage: int | None = None
125
+ balance_version: int | None = None
126
+ manager_bonus: int | None = None
127
+ ib_bonus: int | None = None
128
+ non_withdrawable_bonus: int | None = None
129
+
130
+ def get_balance(self) -> Decimal:
131
+ """Get balance as Decimal.
132
+
133
+ Returns:
134
+ Balance divided by 10^money_digits.
135
+ """
136
+ return Decimal(self.balance) / Decimal(10**self.money_digits)
137
+
138
+ def get_leverage(self) -> str:
139
+ """Get leverage as human-readable string.
140
+
141
+ Returns:
142
+ Leverage string like "1:100".
143
+ """
144
+ leverage = self.leverage_in_cents // 100
145
+ return f"1:{leverage}"
146
+
147
+ @classmethod
148
+ def from_proto(cls, proto: ProtoOATrader) -> Account:
149
+ """Create Account from proto message.
150
+
151
+ Args:
152
+ proto: The proto message to convert.
153
+
154
+ Returns:
155
+ A new Account instance.
156
+ """
157
+ return cls(
158
+ account_id=proto.ctid_trader_account_id,
159
+ trader_login=proto.trader_login,
160
+ balance=proto.balance,
161
+ money_digits=proto.money_digits if proto.money_digits else 2,
162
+ leverage_in_cents=proto.leverage_in_cents,
163
+ account_type=_ACCOUNT_TYPE_MAP.get(proto.account_type, AccountType.HEDGED),
164
+ access_rights=_ACCESS_RIGHTS_MAP.get(proto.access_rights, AccessRights.FULL_ACCESS),
165
+ broker_name=proto.broker_name or "",
166
+ deposit_asset_id=proto.deposit_asset_id,
167
+ swap_free=proto.swap_free,
168
+ is_limited_risk=proto.is_limited_risk,
169
+ registration_timestamp=(
170
+ _timestamp_to_datetime(proto.registration_timestamp) if proto.registration_timestamp else None
171
+ ),
172
+ max_leverage=proto.max_leverage if proto.max_leverage else None,
173
+ balance_version=proto.balance_version if proto.balance_version else None,
174
+ manager_bonus=proto.manager_bonus if proto.manager_bonus else None,
175
+ ib_bonus=proto.ib_bonus if proto.ib_bonus else None,
176
+ non_withdrawable_bonus=proto.non_withdrawable_bonus if proto.non_withdrawable_bonus else None,
177
+ )
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from decimal import Decimal
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._internal.proto import ProtoOADealStatus
8
+ from ..enums import DealStatus, OrderSide
9
+ from ._base import FrozenModel
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from .._internal.proto import ProtoOAClosePositionDetail, ProtoOADeal
14
+ from .symbol import Symbol
15
+
16
+
17
+ def _timestamp_to_datetime(timestamp_ms: int) -> datetime:
18
+ """Convert millisecond timestamp to datetime."""
19
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)
20
+
21
+
22
+ _DEAL_STATUS_MAP: dict[int, DealStatus] = {
23
+ ProtoOADealStatus.FILLED: DealStatus.FILLED,
24
+ ProtoOADealStatus.PARTIALLY_FILLED: DealStatus.PARTIALLY_FILLED,
25
+ ProtoOADealStatus.REJECTED: DealStatus.REJECTED,
26
+ ProtoOADealStatus.INTERNALLY_REJECTED: DealStatus.INTERNALLY_REJECTED,
27
+ ProtoOADealStatus.ERROR: DealStatus.ERROR,
28
+ ProtoOADealStatus.MISSED: DealStatus.MISSED,
29
+ }
30
+
31
+
32
+ class CloseDetail(FrozenModel):
33
+ """Details about position closure from a deal.
34
+
35
+ Present only when a deal results in closing (fully or partially)
36
+ a position.
37
+
38
+ Attributes:
39
+ entry_price: Original entry price of the position.
40
+ closed_volume: Volume that was closed.
41
+ gross_profit: Gross profit/loss (raw integer).
42
+ swap: Swap charged (raw integer).
43
+ commission: Commission charged (raw integer).
44
+ balance: Account balance after close (raw integer).
45
+ money_digits: Decimal places for monetary values.
46
+ pnl_conversion_fee: Fee for P&L currency conversion (raw integer).
47
+ quote_to_deposit_rate: Conversion rate from quote to deposit currency.
48
+ balance_version: Version number for balance updates.
49
+ """
50
+
51
+ entry_price: float
52
+ closed_volume: int
53
+ gross_profit: int
54
+ swap: int
55
+ commission: int
56
+ balance: int
57
+ money_digits: int
58
+ pnl_conversion_fee: int = 0
59
+ quote_to_deposit_rate: float | None = None
60
+ balance_version: int | None = None
61
+
62
+ def get_gross_profit(self) -> Decimal:
63
+ """Get gross profit as Decimal.
64
+
65
+ Returns:
66
+ Gross profit divided by 10^money_digits.
67
+ """
68
+ return Decimal(self.gross_profit) / Decimal(10**self.money_digits)
69
+
70
+ def get_net_profit(self) -> Decimal:
71
+ """Get net profit (gross - swap - commission - fee) as Decimal.
72
+
73
+ Returns:
74
+ Net profit divided by 10^money_digits.
75
+ """
76
+ net = self.gross_profit - self.swap - self.commission - self.pnl_conversion_fee
77
+ return Decimal(net) / Decimal(10**self.money_digits)
78
+
79
+ def get_swap(self) -> Decimal:
80
+ """Get swap as Decimal.
81
+
82
+ Returns:
83
+ Swap divided by 10^money_digits.
84
+ """
85
+ return Decimal(self.swap) / Decimal(10**self.money_digits)
86
+
87
+ def get_commission(self) -> Decimal:
88
+ """Get commission as Decimal.
89
+
90
+ Returns:
91
+ Commission divided by 10^money_digits.
92
+ """
93
+ return Decimal(self.commission) / Decimal(10**self.money_digits)
94
+
95
+ @classmethod
96
+ def from_proto(cls, proto: ProtoOAClosePositionDetail) -> CloseDetail:
97
+ """Create CloseDetail from proto message.
98
+
99
+ Args:
100
+ proto: The proto message to convert.
101
+
102
+ Returns:
103
+ A new CloseDetail instance.
104
+ """
105
+ return cls(
106
+ entry_price=proto.entry_price,
107
+ closed_volume=proto.closed_volume,
108
+ gross_profit=proto.gross_profit,
109
+ swap=proto.swap,
110
+ commission=proto.commission,
111
+ balance=proto.balance,
112
+ money_digits=proto.money_digits if proto.money_digits else 2,
113
+ pnl_conversion_fee=proto.pnl_conversion_fee if proto.pnl_conversion_fee else 0,
114
+ quote_to_deposit_rate=proto.quote_to_deposit_conversion_rate
115
+ if proto.quote_to_deposit_conversion_rate
116
+ else None,
117
+ balance_version=proto.balance_version if proto.balance_version else None,
118
+ )
119
+
120
+
121
+ class Deal(FrozenModel):
122
+ """A trade execution (deal).
123
+
124
+ Represents a single execution that fills an order. An order may have
125
+ multiple deals if it's filled in parts.
126
+
127
+ Attributes:
128
+ deal_id: Unique deal identifier.
129
+ order_id: The order that was executed.
130
+ position_id: The position affected by this deal.
131
+ symbol_id: The symbol traded.
132
+ side: Trade direction (BUY/SELL).
133
+ volume: Requested volume in cents.
134
+ filled_volume: Actually filled volume in cents.
135
+ execution_price: Price at which the deal was executed.
136
+ execution_timestamp: When the deal was executed.
137
+ status: Deal status.
138
+ commission: Commission charged (raw integer).
139
+ money_digits: Decimal places for monetary values.
140
+ create_timestamp: When the deal was created.
141
+ last_update_timestamp: When the deal was last updated.
142
+ margin_rate: Margin rate for the deal.
143
+ base_to_usd_rate: Conversion rate from base currency to USD.
144
+ close_detail: Details if this deal closed a position, or None.
145
+ """
146
+
147
+ deal_id: int
148
+ order_id: int
149
+ position_id: int
150
+ symbol_id: int
151
+ side: OrderSide
152
+ volume: int
153
+ filled_volume: int
154
+ execution_price: float
155
+ execution_timestamp: datetime
156
+ status: DealStatus
157
+ commission: int
158
+ money_digits: int
159
+
160
+ # Optional
161
+ create_timestamp: datetime | None = None
162
+ last_update_timestamp: datetime | None = None
163
+ margin_rate: float | None = None
164
+ base_to_usd_rate: float | None = None
165
+ close_detail: CloseDetail | None = None
166
+
167
+ def get_execution_price(self, symbol: Symbol) -> Decimal:
168
+ """Get execution price as Decimal.
169
+
170
+ Args:
171
+ symbol: Symbol for price precision.
172
+
173
+ Returns:
174
+ Execution price with correct decimal places.
175
+ """
176
+ return Decimal(str(self.execution_price)).quantize(Decimal(10) ** -symbol.digits)
177
+
178
+ def get_commission(self) -> Decimal:
179
+ """Get commission as Decimal.
180
+
181
+ Returns:
182
+ Commission divided by 10^money_digits.
183
+ """
184
+ return Decimal(self.commission) / Decimal(10**self.money_digits)
185
+
186
+ def get_volume_in_lots(self, symbol: Symbol) -> Decimal:
187
+ """Get filled volume in lots.
188
+
189
+ Args:
190
+ symbol: Symbol for lot conversion.
191
+
192
+ Returns:
193
+ Volume in lots.
194
+ """
195
+ return symbol.volume_to_lots(self.filled_volume)
196
+
197
+ @property
198
+ def is_closing_deal(self) -> bool:
199
+ """Whether this deal closed (or partially closed) a position."""
200
+ if self.close_detail is not None:
201
+ return self.close_detail.balance > 0
202
+ else:
203
+ return False
204
+
205
+ @classmethod
206
+ def from_proto(cls, proto: ProtoOADeal) -> Deal:
207
+ """Create Deal from proto message.
208
+
209
+ Args:
210
+ proto: The proto message to convert.
211
+
212
+ Returns:
213
+ A new Deal instance.
214
+ """
215
+ close_detail = None
216
+ if proto.close_position_detail:
217
+ close_detail = CloseDetail.from_proto(proto.close_position_detail)
218
+
219
+ # Determine side
220
+ side = OrderSide.BUY if proto.trade_side == 1 else OrderSide.SELL
221
+
222
+ return cls(
223
+ deal_id=proto.deal_id,
224
+ order_id=proto.order_id,
225
+ position_id=proto.position_id,
226
+ symbol_id=proto.symbol_id,
227
+ side=side,
228
+ volume=proto.volume,
229
+ filled_volume=proto.filled_volume,
230
+ execution_price=proto.execution_price,
231
+ execution_timestamp=_timestamp_to_datetime(proto.execution_timestamp),
232
+ status=_DEAL_STATUS_MAP.get(proto.deal_status, DealStatus.FILLED),
233
+ commission=proto.commission if proto.commission else 0,
234
+ money_digits=proto.money_digits if proto.money_digits else 2,
235
+ create_timestamp=_timestamp_to_datetime(proto.create_timestamp) if proto.create_timestamp else None,
236
+ last_update_timestamp=(
237
+ _timestamp_to_datetime(proto.utc_last_update_timestamp) if proto.utc_last_update_timestamp else None
238
+ ),
239
+ margin_rate=proto.margin_rate if proto.margin_rate else None,
240
+ base_to_usd_rate=proto.base_to_usd_conversion_rate if proto.base_to_usd_conversion_rate else None,
241
+ close_detail=close_detail,
242
+ )
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from decimal import Decimal
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._internal.proto import ProtoOATrendbarPeriod
8
+ from ..enums import TrendbarPeriod
9
+ from ._base import FrozenModel
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from .._internal.proto import ProtoOATickData, ProtoOATrendbar
14
+ from .symbol import Symbol
15
+
16
+
17
+ _PERIOD_MAP: dict[int, TrendbarPeriod] = {
18
+ ProtoOATrendbarPeriod.M1: TrendbarPeriod.M1,
19
+ ProtoOATrendbarPeriod.M2: TrendbarPeriod.M2,
20
+ ProtoOATrendbarPeriod.M3: TrendbarPeriod.M3,
21
+ ProtoOATrendbarPeriod.M4: TrendbarPeriod.M4,
22
+ ProtoOATrendbarPeriod.M5: TrendbarPeriod.M5,
23
+ ProtoOATrendbarPeriod.M10: TrendbarPeriod.M10,
24
+ ProtoOATrendbarPeriod.M15: TrendbarPeriod.M15,
25
+ ProtoOATrendbarPeriod.M30: TrendbarPeriod.M30,
26
+ ProtoOATrendbarPeriod.H1: TrendbarPeriod.H1,
27
+ ProtoOATrendbarPeriod.H4: TrendbarPeriod.H4,
28
+ ProtoOATrendbarPeriod.H12: TrendbarPeriod.H12,
29
+ ProtoOATrendbarPeriod.D1: TrendbarPeriod.D1,
30
+ ProtoOATrendbarPeriod.W1: TrendbarPeriod.W1,
31
+ ProtoOATrendbarPeriod.MN1: TrendbarPeriod.MN1,
32
+ }
33
+
34
+
35
+ class Trendbar(FrozenModel):
36
+ """Historical OHLC bar (candlestick).
37
+
38
+ Note: The raw proto stores prices as deltas from low. This model
39
+ exposes computed open/high/close values.
40
+
41
+ Attributes:
42
+ timestamp: Bar open time.
43
+ period: Bar period (M1, H1, D1, etc.).
44
+ low: Low price (raw integer).
45
+ open: Open price (raw integer).
46
+ high: High price (raw integer).
47
+ close: Close price (raw integer).
48
+ volume: Trade volume.
49
+ """
50
+
51
+ timestamp: datetime
52
+ period: TrendbarPeriod
53
+ low: int
54
+ open: int
55
+ high: int
56
+ close: int
57
+ volume: int
58
+
59
+ def get_ohlc(self, symbol: Symbol) -> tuple[Decimal, Decimal, Decimal, Decimal]:
60
+ """Get OHLC prices as Decimals.
61
+
62
+ Args:
63
+ symbol: Symbol for price conversion.
64
+
65
+ Returns:
66
+ Tuple of (open, high, low, close) as Decimals.
67
+ """
68
+ return (
69
+ symbol.price_to_decimal(self.open),
70
+ symbol.price_to_decimal(self.high),
71
+ symbol.price_to_decimal(self.low),
72
+ symbol.price_to_decimal(self.close),
73
+ )
74
+
75
+ def get_open(self, symbol: Symbol) -> Decimal:
76
+ """Get open price as Decimal.
77
+
78
+ Args:
79
+ symbol: Symbol for price conversion.
80
+
81
+ Returns:
82
+ Open price.
83
+ """
84
+ return symbol.price_to_decimal(self.open)
85
+
86
+ def get_high(self, symbol: Symbol) -> Decimal:
87
+ """Get high price as Decimal.
88
+
89
+ Args:
90
+ symbol: Symbol for price conversion.
91
+
92
+ Returns:
93
+ High price.
94
+ """
95
+ return symbol.price_to_decimal(self.high)
96
+
97
+ def get_low(self, symbol: Symbol) -> Decimal:
98
+ """Get low price as Decimal.
99
+
100
+ Args:
101
+ symbol: Symbol for price conversion.
102
+
103
+ Returns:
104
+ Low price.
105
+ """
106
+ return symbol.price_to_decimal(self.low)
107
+
108
+ def get_close(self, symbol: Symbol) -> Decimal:
109
+ """Get close price as Decimal.
110
+
111
+ Args:
112
+ symbol: Symbol for price conversion.
113
+
114
+ Returns:
115
+ Close price.
116
+ """
117
+ return symbol.price_to_decimal(self.close)
118
+
119
+ @classmethod
120
+ def from_proto(cls, proto: ProtoOATrendbar) -> Trendbar:
121
+ """Create Trendbar from proto message.
122
+
123
+ Args:
124
+ proto: The proto message.
125
+
126
+ Returns:
127
+ A new Trendbar instance.
128
+ """
129
+ # Calculate timestamp from utc_timestamp_in_minutes
130
+ ts = datetime.now(UTC)
131
+ if proto.utc_timestamp_in_minutes:
132
+ ts = datetime.fromtimestamp(proto.utc_timestamp_in_minutes * 60, tz=UTC)
133
+
134
+ # Proto stores: low as absolute, others as deltas from low
135
+ low = proto.low
136
+ open_price = low + proto.delta_open
137
+ high = low + proto.delta_high
138
+ close = low + proto.delta_close
139
+
140
+ return cls(
141
+ timestamp=ts,
142
+ period=_PERIOD_MAP.get(proto.period, TrendbarPeriod.M1),
143
+ low=low,
144
+ open=open_price,
145
+ high=high,
146
+ close=close,
147
+ volume=proto.volume,
148
+ )
149
+
150
+
151
+ class TickData(FrozenModel):
152
+ """Historical tick data point.
153
+
154
+ Represents a single price tick from tick history.
155
+
156
+ Attributes:
157
+ timestamp: Tick time.
158
+ price: Tick price (raw integer).
159
+ """
160
+
161
+ timestamp: datetime
162
+ price: int
163
+
164
+ def get_price(self, symbol: Symbol) -> Decimal:
165
+ """Get price as Decimal.
166
+
167
+ Args:
168
+ symbol: Symbol for price conversion.
169
+
170
+ Returns:
171
+ Price as Decimal.
172
+ """
173
+ return symbol.price_to_decimal(self.price)
174
+
175
+ @classmethod
176
+ def from_proto(cls, proto: ProtoOATickData, base_timestamp_ms: int = 0) -> TickData:
177
+ """Create TickData from proto message.
178
+
179
+ Args:
180
+ proto: The proto message.
181
+ base_timestamp_ms: Base timestamp in milliseconds. Proto timestamp
182
+ is a delta from this base.
183
+
184
+ Returns:
185
+ A new TickData instance.
186
+ """
187
+ # Proto timestamp is delta from base in milliseconds
188
+ actual_ts = base_timestamp_ms + proto.timestamp
189
+ return cls(
190
+ timestamp=datetime.fromtimestamp(actual_ts / 1000, tz=UTC),
191
+ price=proto.tick,
192
+ )