nemo-cli 0.0.1__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.
@@ -0,0 +1,342 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from typing import Any, Literal, cast
4
+
5
+ from nemo_cli.api.client import api_request
6
+
7
+ MovementKind = Literal[
8
+ "dividend",
9
+ "buy",
10
+ "sell",
11
+ "commission",
12
+ "cash_in",
13
+ "cash_out",
14
+ "other",
15
+ ]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class DividendInfo:
20
+ ex_date: str | None
21
+ nemotecnico: str
22
+ per_unit_amount: float
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class TradeInfo:
27
+ nemotecnico: str
28
+ side: Literal["buy", "sell"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Movement:
33
+ cash_bucket_id: int
34
+ cash_bucket_name: str
35
+ account: str
36
+ sequence: int
37
+ movement_date: str
38
+ settlement_date: str
39
+ description: str
40
+ kind: MovementKind
41
+ credit: float
42
+ debit: float
43
+ balance: float
44
+ currency: str
45
+ dividend: DividendInfo | None
46
+ trade: TradeInfo | None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class DividendSummaryItem:
51
+ nemotecnico: str
52
+ total_received: float
53
+ occurrences: int
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class TradeSummaryItem:
58
+ nemotecnico: str
59
+ side: Literal["buy", "sell"]
60
+ total_amount: float
61
+ occurrences: int
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class MovementsSummary:
66
+ total_cash_in: float
67
+ total_cash_out: float
68
+ total_dividends: float
69
+ total_commissions: float
70
+ total_buys: float
71
+ total_sells: float
72
+ by_dividend: tuple[DividendSummaryItem, ...]
73
+ by_trade: tuple[TradeSummaryItem, ...]
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class CashBucket:
78
+ bucket_id: int
79
+ name: str
80
+ movements: tuple[Movement, ...]
81
+ summary: MovementsSummary
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Movements:
86
+ desde: str
87
+ hasta: str
88
+ buckets: tuple[CashBucket, ...]
89
+ summary: MovementsSummary
90
+
91
+
92
+ _DIVIDEND_PATTERN = re.compile(
93
+ r"^DIV;(\d{1,2}-\d{1,2}-\d{4});([^;]+);([\d,\.]+)$"
94
+ )
95
+ _TRADE_PATTERN = re.compile(r"^(COMPRA|VENTA) FONDOS CB - (\S+)$")
96
+ _COMMISSION_TEXT = "COMISION TRANSACCIÓN BOLSA"
97
+ _RUT_PATTERN = re.compile(r"^\d{1,2}(\.\d{3}){0,2}-[\dkK]$")
98
+
99
+
100
+ def get_movements(
101
+ *,
102
+ desde: str,
103
+ hasta: str,
104
+ account_id: int = 0,
105
+ ) -> Movements:
106
+ params: dict[str, Any] = {
107
+ "id": account_id,
108
+ "tipo": "Cuenta",
109
+ "desde": desde,
110
+ "hasta": hasta,
111
+ }
112
+ # The movements endpoint is markedly slower than the rest. The 180s value
113
+ # is applied by httpx to every phase (connect / read / write / pool); read
114
+ # is the only one that realistically pushes the limit when the server
115
+ # generates a year of activity in one shot. If even this proves
116
+ # insufficient, switch to a structured `httpx.Timeout(connect=10, read=…)`
117
+ # via an `api_request` signature change.
118
+ response = api_request(
119
+ "GET",
120
+ "/frontoffice/shared/MovimientosCajas/Movimientos",
121
+ params=params,
122
+ timeout=180.0,
123
+ )
124
+ if response.status_code >= 400:
125
+ raise RuntimeError(
126
+ f"Failed to load movements "
127
+ f"({response.status_code} {response.reason_phrase}): {response.text}"
128
+ )
129
+
130
+ raw: object = response.json()
131
+ if not isinstance(raw, list):
132
+ raise RuntimeError("Movements response was not a JSON array.")
133
+
134
+ buckets = tuple(
135
+ _to_bucket(cast(dict[str, object], item))
136
+ for item in cast(list[object], raw)
137
+ if isinstance(item, dict)
138
+ )
139
+
140
+ grand_movements = tuple(m for bucket in buckets for m in bucket.movements)
141
+ grand_summary = _summarize(grand_movements)
142
+
143
+ return Movements(
144
+ desde=desde,
145
+ hasta=hasta,
146
+ buckets=buckets,
147
+ summary=grand_summary,
148
+ )
149
+
150
+
151
+ def _to_bucket(raw: dict[str, object]) -> CashBucket:
152
+ bucket_id = _as_int(raw.get("idCajaCuenta"))
153
+ name = _as_str(raw.get("dscCajaCuenta"))
154
+ raw_movs = raw.get("movimientos")
155
+ if not isinstance(raw_movs, list):
156
+ movements: tuple[Movement, ...] = ()
157
+ else:
158
+ movements = tuple(
159
+ _to_movement(cast(dict[str, object], item), bucket_id, name)
160
+ for item in cast(list[object], raw_movs)
161
+ if isinstance(item, dict)
162
+ )
163
+ return CashBucket(
164
+ bucket_id=bucket_id,
165
+ name=name,
166
+ movements=movements,
167
+ summary=_summarize(movements),
168
+ )
169
+
170
+
171
+ def _to_movement(
172
+ raw: dict[str, object],
173
+ bucket_id_default: int,
174
+ bucket_name_default: str,
175
+ ) -> Movement:
176
+ description = _as_str(raw.get("movimiento"))
177
+ credit = _as_float(raw.get("abono"))
178
+ debit = _as_float(raw.get("cargo"))
179
+
180
+ kind, dividend, trade = _classify(description, credit, debit)
181
+
182
+ return Movement(
183
+ cash_bucket_id=_as_int(raw.get("idCajaCuenta")) or bucket_id_default,
184
+ cash_bucket_name=_as_str(raw.get("dscCajaCuenta")) or bucket_name_default,
185
+ account=_as_str(raw.get("numCuenta")),
186
+ sequence=_as_int(raw.get("orden")),
187
+ movement_date=_as_date(raw.get("fechaMovimiento")),
188
+ settlement_date=_as_date(raw.get("fechaLiquidacion")),
189
+ description=description,
190
+ kind=kind,
191
+ credit=credit,
192
+ debit=debit,
193
+ balance=_as_float(raw.get("saldo")),
194
+ currency=_as_str(raw.get("codMoneda")),
195
+ dividend=dividend,
196
+ trade=trade,
197
+ )
198
+
199
+
200
+ def _classify(
201
+ description: str,
202
+ credit: float,
203
+ debit: float,
204
+ ) -> tuple[MovementKind, DividendInfo | None, TradeInfo | None]:
205
+ cleaned = description.strip()
206
+
207
+ div_match = _DIVIDEND_PATTERN.match(cleaned)
208
+ if div_match is not None:
209
+ ex_dmy, nemo, amount_str = div_match.groups()
210
+ ex_iso: str | None
211
+ try:
212
+ day_str, month_str, year_str = ex_dmy.split("-")
213
+ ex_iso = f"{int(year_str):04d}-{int(month_str):02d}-{int(day_str):02d}"
214
+ except ValueError:
215
+ ex_iso = None
216
+ try:
217
+ per_unit = float(amount_str.replace(",", "."))
218
+ except ValueError:
219
+ per_unit = 0.0
220
+ return (
221
+ "dividend",
222
+ DividendInfo(
223
+ ex_date=ex_iso,
224
+ nemotecnico=nemo.strip(),
225
+ per_unit_amount=per_unit,
226
+ ),
227
+ None,
228
+ )
229
+
230
+ trade_match = _TRADE_PATTERN.match(cleaned)
231
+ if trade_match is not None:
232
+ side_es, nemo = trade_match.groups()
233
+ side: Literal["buy", "sell"] = "buy" if side_es == "COMPRA" else "sell"
234
+ return side, None, TradeInfo(nemotecnico=nemo.strip(), side=side)
235
+
236
+ if cleaned == _COMMISSION_TEXT:
237
+ return "commission", None, None
238
+
239
+ if _RUT_PATTERN.match(cleaned):
240
+ if credit > 0:
241
+ return "cash_in", None, None
242
+ if debit > 0:
243
+ return "cash_out", None, None
244
+
245
+ return "other", None, None
246
+
247
+
248
+ def _summarize(movements: tuple[Movement, ...]) -> MovementsSummary:
249
+ total_cash_in = 0.0
250
+ total_cash_out = 0.0
251
+ total_dividends = 0.0
252
+ total_commissions = 0.0
253
+ total_buys = 0.0
254
+ total_sells = 0.0
255
+
256
+ div_totals: dict[str, list[float]] = {}
257
+ trade_totals: dict[tuple[str, str], list[float]] = {}
258
+
259
+ for mov in movements:
260
+ if mov.kind == "cash_in":
261
+ total_cash_in += mov.credit
262
+ elif mov.kind == "cash_out":
263
+ total_cash_out += mov.debit
264
+ elif mov.kind == "commission":
265
+ total_commissions += mov.debit
266
+ elif mov.kind == "dividend":
267
+ total_dividends += mov.credit
268
+ if mov.dividend is not None:
269
+ bucket = div_totals.setdefault(mov.dividend.nemotecnico, [])
270
+ bucket.append(mov.credit)
271
+ elif mov.kind == "buy":
272
+ total_buys += mov.debit
273
+ if mov.trade is not None:
274
+ key = (mov.trade.nemotecnico, mov.trade.side)
275
+ trade_totals.setdefault(key, []).append(mov.debit)
276
+ elif mov.kind == "sell":
277
+ total_sells += mov.credit
278
+ if mov.trade is not None:
279
+ key = (mov.trade.nemotecnico, mov.trade.side)
280
+ trade_totals.setdefault(key, []).append(mov.credit)
281
+ # "other" kinds intentionally do not contribute to any total.
282
+
283
+ by_dividend = tuple(
284
+ sorted(
285
+ (
286
+ DividendSummaryItem(
287
+ nemotecnico=nemo,
288
+ total_received=sum(amounts),
289
+ occurrences=len(amounts),
290
+ )
291
+ for nemo, amounts in div_totals.items()
292
+ ),
293
+ key=lambda item: item.total_received,
294
+ reverse=True,
295
+ )
296
+ )
297
+
298
+ by_trade = tuple(
299
+ sorted(
300
+ (
301
+ TradeSummaryItem(
302
+ nemotecnico=nemo,
303
+ side=cast(Literal["buy", "sell"], side),
304
+ total_amount=sum(amounts),
305
+ occurrences=len(amounts),
306
+ )
307
+ for (nemo, side), amounts in trade_totals.items()
308
+ ),
309
+ key=lambda item: (item.nemotecnico, item.side),
310
+ )
311
+ )
312
+
313
+ return MovementsSummary(
314
+ total_cash_in=total_cash_in,
315
+ total_cash_out=total_cash_out,
316
+ total_dividends=total_dividends,
317
+ total_commissions=total_commissions,
318
+ total_buys=total_buys,
319
+ total_sells=total_sells,
320
+ by_dividend=by_dividend,
321
+ by_trade=by_trade,
322
+ )
323
+
324
+
325
+ def _as_str(value: object) -> str:
326
+ return value.strip() if isinstance(value, str) else ""
327
+
328
+
329
+ def _as_int(value: object) -> int:
330
+ return value if isinstance(value, int) else 0
331
+
332
+
333
+ def _as_float(value: object) -> float:
334
+ if isinstance(value, (int, float)):
335
+ return float(value)
336
+ return 0.0
337
+
338
+
339
+ def _as_date(value: object) -> str:
340
+ if isinstance(value, str) and len(value) >= 10:
341
+ return value[:10]
342
+ return value if isinstance(value, str) else ""
@@ -0,0 +1,185 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, cast
3
+
4
+ from nemo_cli.api.client import api_request
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class PortfolioHolding:
9
+ account: str
10
+ account_description: str
11
+ classification: str
12
+ sub_classification: str
13
+ instrument_id: int
14
+ nemotecnico: str
15
+ descripcion: str
16
+ sub_class: str
17
+ series: str | None
18
+ currency: str
19
+ quantity: float
20
+ avg_buy_price: float
21
+ market_price: float
22
+ cost_basis: float
23
+ market_value: float
24
+ pnl: float
25
+ pnl_pct: float
26
+ query_date: str | None
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ClassificationTotal:
31
+ classification: str
32
+ market_value: float
33
+ cost_basis: float
34
+ pnl: float
35
+ pnl_pct: float
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class PortfolioTotals:
40
+ market_value: float
41
+ cost_basis: float
42
+ pnl: float
43
+ pnl_pct: float
44
+ by_classification: tuple[ClassificationTotal, ...]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Portfolio:
49
+ currency: str
50
+ query_date: str | None
51
+ holdings: tuple[PortfolioHolding, ...]
52
+ totals: PortfolioTotals
53
+
54
+
55
+ def get_portfolio_summary(
56
+ *,
57
+ account_id: int = 0,
58
+ currency: str = "CLP",
59
+ with_dividends: bool = True,
60
+ ) -> Portfolio:
61
+ params: dict[str, Any] = {
62
+ "id": account_id,
63
+ "tipo": "Cuenta",
64
+ "codMonedaSld": currency,
65
+ "conDividendos": "true" if with_dividends else "false",
66
+ }
67
+ response = api_request(
68
+ "GET",
69
+ "/frontoffice/shared/cartera/CierreCarteraResumidaOnline",
70
+ params=params,
71
+ )
72
+ if response.status_code >= 400:
73
+ raise RuntimeError(
74
+ f"Failed to load portfolio summary "
75
+ f"({response.status_code} {response.reason_phrase}): {response.text}"
76
+ )
77
+
78
+ raw: object = response.json()
79
+ if not isinstance(raw, list):
80
+ raise RuntimeError("Portfolio summary response was not a JSON array.")
81
+
82
+ holdings = tuple(
83
+ _to_holding(cast(dict[str, object], item))
84
+ for item in cast(list[object], raw)
85
+ if isinstance(item, dict)
86
+ )
87
+ totals = _compute_totals(holdings)
88
+ query_date = _latest_query_date(holdings)
89
+
90
+ return Portfolio(
91
+ currency=currency,
92
+ query_date=query_date,
93
+ holdings=holdings,
94
+ totals=totals,
95
+ )
96
+
97
+
98
+ def _to_holding(item: dict[str, object]) -> PortfolioHolding:
99
+ cost_basis = _as_float(item.get("valorPresenteCompraMonDflt"))
100
+ market_value = _as_float(item.get("valorPresenteMercadoMonDflt"))
101
+ pnl = market_value - cost_basis
102
+ pnl_pct = (pnl / cost_basis) if cost_basis > 0 else 0.0
103
+
104
+ return PortfolioHolding(
105
+ account=_as_str(item.get("cuenta")) or _as_str(item.get("numCuenta")),
106
+ account_description=_as_str(item.get("dscCuenta")),
107
+ classification=_as_str(item.get("clasificacion")),
108
+ sub_classification=_as_str(item.get("subClasificacion")),
109
+ instrument_id=_as_int(item.get("idInstrumento")),
110
+ nemotecnico=_as_str(item.get("nemotecnico")),
111
+ descripcion=_as_str(item.get("dscInstrumento")),
112
+ sub_class=_as_str(item.get("codSubClaseInstrumento")),
113
+ series=_as_optional_str(item.get("serie")),
114
+ currency=_as_str(item.get("codMonedaDflt")),
115
+ quantity=_as_float(item.get("cantidad")),
116
+ avg_buy_price=_as_float(item.get("tasaPrecioCompra")),
117
+ market_price=_as_float(item.get("tasaPrecio")),
118
+ cost_basis=cost_basis,
119
+ market_value=market_value,
120
+ pnl=pnl,
121
+ pnl_pct=pnl_pct,
122
+ query_date=_as_optional_str(item.get("fechaConsulta")),
123
+ )
124
+
125
+
126
+ def _compute_totals(holdings: tuple[PortfolioHolding, ...]) -> PortfolioTotals:
127
+ by_class: dict[str, list[PortfolioHolding]] = {}
128
+ for holding in holdings:
129
+ by_class.setdefault(holding.classification, []).append(holding)
130
+
131
+ classification_totals: list[ClassificationTotal] = []
132
+ for classification in sorted(by_class):
133
+ items = by_class[classification]
134
+ market = sum(h.market_value for h in items)
135
+ cost = sum(h.cost_basis for h in items)
136
+ pnl = market - cost
137
+ pnl_pct = (pnl / cost) if cost > 0 else 0.0
138
+ classification_totals.append(
139
+ ClassificationTotal(
140
+ classification=classification,
141
+ market_value=market,
142
+ cost_basis=cost,
143
+ pnl=pnl,
144
+ pnl_pct=pnl_pct,
145
+ )
146
+ )
147
+
148
+ total_market = sum(h.market_value for h in holdings)
149
+ total_cost = sum(h.cost_basis for h in holdings)
150
+ total_pnl = total_market - total_cost
151
+ total_pnl_pct = (total_pnl / total_cost) if total_cost > 0 else 0.0
152
+
153
+ return PortfolioTotals(
154
+ market_value=total_market,
155
+ cost_basis=total_cost,
156
+ pnl=total_pnl,
157
+ pnl_pct=total_pnl_pct,
158
+ by_classification=tuple(classification_totals),
159
+ )
160
+
161
+
162
+ def _latest_query_date(holdings: tuple[PortfolioHolding, ...]) -> str | None:
163
+ dates = [h.query_date for h in holdings if h.query_date]
164
+ return max(dates) if dates else None
165
+
166
+
167
+ def _as_str(value: object) -> str:
168
+ return value.strip() if isinstance(value, str) else ""
169
+
170
+
171
+ def _as_optional_str(value: object) -> str | None:
172
+ if isinstance(value, str):
173
+ cleaned = value.strip()
174
+ return cleaned or None
175
+ return None
176
+
177
+
178
+ def _as_int(value: object) -> int:
179
+ return value if isinstance(value, int) else 0
180
+
181
+
182
+ def _as_float(value: object) -> float:
183
+ if isinstance(value, (int, float)):
184
+ return float(value)
185
+ return 0.0