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.
- nemo_cli/__init__.py +1 -0
- nemo_cli/__main__.py +4 -0
- nemo_cli/api/__init__.py +0 -0
- nemo_cli/api/client.py +81 -0
- nemo_cli/auth/__init__.py +0 -0
- nemo_cli/auth/jwt.py +47 -0
- nemo_cli/auth/service.py +56 -0
- nemo_cli/auth/token_store.py +37 -0
- nemo_cli/cli.py +46 -0
- nemo_cli/commands/__init__.py +0 -0
- nemo_cli/commands/instruments.py +271 -0
- nemo_cli/commands/login.py +15 -0
- nemo_cli/commands/logout.py +9 -0
- nemo_cli/commands/portfolio.py +275 -0
- nemo_cli/commands/whoami.py +18 -0
- nemo_cli/config.py +24 -0
- nemo_cli/instruments/__init__.py +0 -0
- nemo_cli/instruments/international.py +102 -0
- nemo_cli/instruments/local.py +94 -0
- nemo_cli/instruments/prices.py +139 -0
- nemo_cli/portfolio/__init__.py +0 -0
- nemo_cli/portfolio/movements.py +342 -0
- nemo_cli/portfolio/summary.py +185 -0
- nemo_cli-0.0.1.dist-info/METADATA +311 -0
- nemo_cli-0.0.1.dist-info/RECORD +28 -0
- nemo_cli-0.0.1.dist-info/WHEEL +4 -0
- nemo_cli-0.0.1.dist-info/entry_points.txt +2 -0
- nemo_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|