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,275 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
from datetime import date, timedelta
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from nemo_cli.portfolio.movements import (
|
|
10
|
+
Movements,
|
|
11
|
+
MovementsSummary,
|
|
12
|
+
get_movements,
|
|
13
|
+
)
|
|
14
|
+
from nemo_cli.portfolio.summary import (
|
|
15
|
+
Portfolio,
|
|
16
|
+
PortfolioTotals,
|
|
17
|
+
get_portfolio_summary,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
help="Inspect your portfolio.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("summary")
|
|
27
|
+
def summary(
|
|
28
|
+
account_id: int = typer.Option(
|
|
29
|
+
0, "--account-id", help="Account id to filter on (0 = all accounts)."
|
|
30
|
+
),
|
|
31
|
+
currency: str = typer.Option(
|
|
32
|
+
"CLP", "--currency", help="Base currency for balances (codMonedaSld)."
|
|
33
|
+
),
|
|
34
|
+
with_dividends: bool = typer.Option(
|
|
35
|
+
True,
|
|
36
|
+
"--with-dividends/--no-dividends",
|
|
37
|
+
help="Include dividends in the summary (conDividendos).",
|
|
38
|
+
),
|
|
39
|
+
as_json: bool = typer.Option(
|
|
40
|
+
False, "--json", help="Emit JSON (holdings + totals) instead of a table."
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Show the portfolio summary: holdings, P&L, and totals by classification."""
|
|
44
|
+
try:
|
|
45
|
+
portfolio = get_portfolio_summary(
|
|
46
|
+
account_id=account_id,
|
|
47
|
+
currency=currency,
|
|
48
|
+
with_dividends=with_dividends,
|
|
49
|
+
)
|
|
50
|
+
except Exception as error:
|
|
51
|
+
typer.secho(str(error), fg=typer.colors.RED, err=True)
|
|
52
|
+
raise typer.Exit(code=1) from error
|
|
53
|
+
|
|
54
|
+
if as_json:
|
|
55
|
+
_print_json(portfolio)
|
|
56
|
+
else:
|
|
57
|
+
_print_table(portfolio)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _print_table(portfolio: Portfolio) -> None:
|
|
61
|
+
console = Console()
|
|
62
|
+
title = f"Portfolio summary — {portfolio.query_date or 'n/a'} ({portfolio.currency})"
|
|
63
|
+
if not portfolio.holdings:
|
|
64
|
+
console.print(f"[yellow]{title}: no holdings returned.[/yellow]")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
holdings_table = Table(title=title)
|
|
68
|
+
holdings_table.add_column("Nemotécnico", style="cyan", no_wrap=True)
|
|
69
|
+
holdings_table.add_column("Descripción")
|
|
70
|
+
holdings_table.add_column("Subclasificación", style="yellow")
|
|
71
|
+
holdings_table.add_column("Cantidad", justify="right")
|
|
72
|
+
holdings_table.add_column("Costo", justify="right")
|
|
73
|
+
holdings_table.add_column("Mercado", justify="right")
|
|
74
|
+
holdings_table.add_column("P&L", justify="right")
|
|
75
|
+
holdings_table.add_column("P&L %", justify="right")
|
|
76
|
+
|
|
77
|
+
for holding in portfolio.holdings:
|
|
78
|
+
color = "green" if holding.pnl >= 0 else "red"
|
|
79
|
+
holdings_table.add_row(
|
|
80
|
+
holding.nemotecnico,
|
|
81
|
+
holding.descripcion,
|
|
82
|
+
holding.sub_classification,
|
|
83
|
+
f"{holding.quantity:,.2f}",
|
|
84
|
+
f"{holding.cost_basis:,.0f}",
|
|
85
|
+
f"{holding.market_value:,.0f}",
|
|
86
|
+
f"[{color}]{holding.pnl:+,.0f}[/{color}]",
|
|
87
|
+
f"[{color}]{holding.pnl_pct * 100:+.2f}%[/{color}]",
|
|
88
|
+
)
|
|
89
|
+
console.print(holdings_table)
|
|
90
|
+
console.print()
|
|
91
|
+
|
|
92
|
+
_print_totals(console, portfolio.totals, portfolio.currency)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _print_totals(console: Console, totals: PortfolioTotals, currency: str) -> None:
|
|
96
|
+
by_class_table = Table(title=f"By classification ({currency})")
|
|
97
|
+
by_class_table.add_column("Clasificación", style="bold")
|
|
98
|
+
by_class_table.add_column("Mercado", justify="right")
|
|
99
|
+
by_class_table.add_column("Costo", justify="right")
|
|
100
|
+
by_class_table.add_column("P&L", justify="right")
|
|
101
|
+
by_class_table.add_column("P&L %", justify="right")
|
|
102
|
+
|
|
103
|
+
for classification in totals.by_classification:
|
|
104
|
+
color = "green" if classification.pnl >= 0 else "red"
|
|
105
|
+
by_class_table.add_row(
|
|
106
|
+
classification.classification,
|
|
107
|
+
f"{classification.market_value:,.0f}",
|
|
108
|
+
f"{classification.cost_basis:,.0f}",
|
|
109
|
+
f"[{color}]{classification.pnl:+,.0f}[/{color}]",
|
|
110
|
+
f"[{color}]{classification.pnl_pct * 100:+.2f}%[/{color}]",
|
|
111
|
+
)
|
|
112
|
+
console.print(by_class_table)
|
|
113
|
+
console.print()
|
|
114
|
+
|
|
115
|
+
color = "green" if totals.pnl >= 0 else "red"
|
|
116
|
+
console.print(
|
|
117
|
+
f"[bold]Total:[/bold] {totals.market_value:,.0f} {currency} "
|
|
118
|
+
f"(cost {totals.cost_basis:,.0f}) "
|
|
119
|
+
f"P&L [{color}]{totals.pnl:+,.0f}[/{color}] "
|
|
120
|
+
f"([{color}]{totals.pnl_pct * 100:+.2f}%[/{color}])"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _print_json(portfolio: Portfolio) -> None:
|
|
125
|
+
payload = {
|
|
126
|
+
"currency": portfolio.currency,
|
|
127
|
+
"query_date": portfolio.query_date,
|
|
128
|
+
"totals": asdict(portfolio.totals),
|
|
129
|
+
"holdings": [asdict(h) for h in portfolio.holdings],
|
|
130
|
+
}
|
|
131
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _default_desde() -> str:
|
|
135
|
+
return (date.today() - timedelta(days=365)).isoformat()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _default_hasta() -> str:
|
|
139
|
+
return date.today().isoformat()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("movements")
|
|
143
|
+
def movements(
|
|
144
|
+
desde: str = typer.Option(
|
|
145
|
+
None,
|
|
146
|
+
"--desde",
|
|
147
|
+
help="Start date YYYY-MM-DD. Default: 1 year ago.",
|
|
148
|
+
),
|
|
149
|
+
hasta: str = typer.Option(
|
|
150
|
+
None,
|
|
151
|
+
"--hasta",
|
|
152
|
+
help="End date YYYY-MM-DD. Default: today.",
|
|
153
|
+
),
|
|
154
|
+
account_id: int = typer.Option(
|
|
155
|
+
0, "--account-id", help="Account id to filter on (0 = all accounts)."
|
|
156
|
+
),
|
|
157
|
+
as_json: bool = typer.Option(
|
|
158
|
+
False,
|
|
159
|
+
"--json",
|
|
160
|
+
help="Emit JSON (full movements + summaries) instead of a panel.",
|
|
161
|
+
),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""List cash movements over a date range, classified per kind.
|
|
164
|
+
|
|
165
|
+
Movement descriptions are parsed into structured `kind` values
|
|
166
|
+
(`dividend`, `buy`, `sell`, `commission`, `cash_in`, `cash_out`,
|
|
167
|
+
`other`) plus per-nemotécnico aggregates — handy for dividend yield
|
|
168
|
+
analysis. Default window is the last 365 days.
|
|
169
|
+
"""
|
|
170
|
+
desde_value = desde or _default_desde()
|
|
171
|
+
hasta_value = hasta or _default_hasta()
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
result = get_movements(
|
|
175
|
+
desde=desde_value,
|
|
176
|
+
hasta=hasta_value,
|
|
177
|
+
account_id=account_id,
|
|
178
|
+
)
|
|
179
|
+
except Exception as error:
|
|
180
|
+
typer.secho(str(error), fg=typer.colors.RED, err=True)
|
|
181
|
+
raise typer.Exit(code=1) from error
|
|
182
|
+
|
|
183
|
+
if as_json:
|
|
184
|
+
_print_json_movements(result)
|
|
185
|
+
else:
|
|
186
|
+
_print_movements_panel(result)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _print_movements_panel(result: Movements) -> None:
|
|
190
|
+
console = Console()
|
|
191
|
+
title = f"Movements — {result.desde} → {result.hasta}"
|
|
192
|
+
if not result.buckets:
|
|
193
|
+
console.print(f"[yellow]{title}: no movements returned.[/yellow]")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
summary = result.summary
|
|
197
|
+
bucket_currencies = sorted({b.movements[0].currency for b in result.buckets if b.movements})
|
|
198
|
+
currency_label = " / ".join(bucket_currencies) or "—"
|
|
199
|
+
|
|
200
|
+
totals_table = Table(title=f"{title} ({currency_label})")
|
|
201
|
+
totals_table.add_column("Flow", style="bold")
|
|
202
|
+
totals_table.add_column("Amount", justify="right")
|
|
203
|
+
totals_table.add_row("Cash in", f"[green]+{summary.total_cash_in:,.0f}[/green]")
|
|
204
|
+
totals_table.add_row("Cash out", f"[red]-{summary.total_cash_out:,.0f}[/red]")
|
|
205
|
+
totals_table.add_row(
|
|
206
|
+
"Dividends",
|
|
207
|
+
f"[green]+{summary.total_dividends:,.0f}[/green]",
|
|
208
|
+
)
|
|
209
|
+
totals_table.add_row("Commissions", f"[red]-{summary.total_commissions:,.0f}[/red]")
|
|
210
|
+
totals_table.add_row("Buys", f"[red]-{summary.total_buys:,.0f}[/red]")
|
|
211
|
+
totals_table.add_row("Sells", f"[green]+{summary.total_sells:,.0f}[/green]")
|
|
212
|
+
console.print(totals_table)
|
|
213
|
+
console.print()
|
|
214
|
+
|
|
215
|
+
if summary.by_dividend:
|
|
216
|
+
div_table = Table(title="Dividends by instrument")
|
|
217
|
+
div_table.add_column("Nemotécnico", style="cyan", no_wrap=True)
|
|
218
|
+
div_table.add_column("Events", justify="right")
|
|
219
|
+
div_table.add_column("Total received", justify="right")
|
|
220
|
+
for item in summary.by_dividend:
|
|
221
|
+
div_table.add_row(
|
|
222
|
+
item.nemotecnico,
|
|
223
|
+
str(item.occurrences),
|
|
224
|
+
f"[green]+{item.total_received:,.0f}[/green]",
|
|
225
|
+
)
|
|
226
|
+
console.print(div_table)
|
|
227
|
+
console.print()
|
|
228
|
+
|
|
229
|
+
if summary.by_trade:
|
|
230
|
+
trade_table = Table(title="Trades by instrument")
|
|
231
|
+
trade_table.add_column("Nemotécnico", style="cyan", no_wrap=True)
|
|
232
|
+
trade_table.add_column("Side", style="magenta")
|
|
233
|
+
trade_table.add_column("Events", justify="right")
|
|
234
|
+
trade_table.add_column("Total amount", justify="right")
|
|
235
|
+
for item in summary.by_trade:
|
|
236
|
+
color = "red" if item.side == "buy" else "green"
|
|
237
|
+
sign = "-" if item.side == "buy" else "+"
|
|
238
|
+
trade_table.add_row(
|
|
239
|
+
item.nemotecnico,
|
|
240
|
+
item.side,
|
|
241
|
+
str(item.occurrences),
|
|
242
|
+
f"[{color}]{sign}{item.total_amount:,.0f}[/{color}]",
|
|
243
|
+
)
|
|
244
|
+
console.print(trade_table)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _print_json_movements(result: Movements) -> None:
|
|
248
|
+
payload: dict[str, object] = {
|
|
249
|
+
"desde": result.desde,
|
|
250
|
+
"hasta": result.hasta,
|
|
251
|
+
"summary": _summary_to_dict(result.summary),
|
|
252
|
+
"buckets": [
|
|
253
|
+
{
|
|
254
|
+
"bucket_id": bucket.bucket_id,
|
|
255
|
+
"name": bucket.name,
|
|
256
|
+
"summary": _summary_to_dict(bucket.summary),
|
|
257
|
+
"movements": [asdict(m) for m in bucket.movements],
|
|
258
|
+
}
|
|
259
|
+
for bucket in result.buckets
|
|
260
|
+
],
|
|
261
|
+
}
|
|
262
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _summary_to_dict(summary: MovementsSummary) -> dict[str, object]:
|
|
266
|
+
return {
|
|
267
|
+
"total_cash_in": summary.total_cash_in,
|
|
268
|
+
"total_cash_out": summary.total_cash_out,
|
|
269
|
+
"total_dividends": summary.total_dividends,
|
|
270
|
+
"total_commissions": summary.total_commissions,
|
|
271
|
+
"total_buys": summary.total_buys,
|
|
272
|
+
"total_sells": summary.total_sells,
|
|
273
|
+
"by_dividend": [asdict(item) for item in summary.by_dividend],
|
|
274
|
+
"by_trade": [asdict(item) for item in summary.by_trade],
|
|
275
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from nemo_cli.auth.token_store import get_token
|
|
4
|
+
from nemo_cli.config import load_credentials
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def whoami() -> None:
|
|
8
|
+
"""Show the configured user and whether a token is currently cached."""
|
|
9
|
+
try:
|
|
10
|
+
credentials = load_credentials()
|
|
11
|
+
except Exception as error:
|
|
12
|
+
typer.secho(str(error), fg=typer.colors.RED, err=True)
|
|
13
|
+
raise typer.Exit(code=1) from error
|
|
14
|
+
typer.echo(f"User: {credentials.user_name}")
|
|
15
|
+
if get_token():
|
|
16
|
+
typer.secho("Token: cached", fg=typer.colors.GREEN)
|
|
17
|
+
else:
|
|
18
|
+
typer.secho("Token: not cached (run `nemo login`)", fg=typer.colors.YELLOW)
|
nemo_cli/config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
load_dotenv()
|
|
7
|
+
|
|
8
|
+
API_BASE_URL = "https://portalclientes.vectorcapital.cl/api"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Credentials:
|
|
13
|
+
user_name: str
|
|
14
|
+
password: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_credentials() -> Credentials:
|
|
18
|
+
user_name = (os.environ.get("NEMO_USERNAME") or "").strip()
|
|
19
|
+
password = os.environ.get("NEMO_PASSWORD") or ""
|
|
20
|
+
if not user_name or not password:
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
"Missing credentials. Set NEMO_USERNAME and NEMO_PASSWORD (e.g. in .env)."
|
|
23
|
+
)
|
|
24
|
+
return Credentials(user_name=user_name, password=password)
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
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 InternationalAsset:
|
|
9
|
+
asset_id: str
|
|
10
|
+
symbol: str
|
|
11
|
+
name: str
|
|
12
|
+
exchange: str
|
|
13
|
+
asset_class: str
|
|
14
|
+
status: str
|
|
15
|
+
tradable: bool
|
|
16
|
+
shortable: bool
|
|
17
|
+
fractionable: bool
|
|
18
|
+
volume: int
|
|
19
|
+
trade_count: int
|
|
20
|
+
cusip: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class InternationalAssetsPage:
|
|
25
|
+
items: tuple[InternationalAsset, ...]
|
|
26
|
+
total: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_international_assets(
|
|
30
|
+
*,
|
|
31
|
+
search: str = "",
|
|
32
|
+
exchange: str = "",
|
|
33
|
+
page: int = 1,
|
|
34
|
+
page_size: int = 30,
|
|
35
|
+
) -> InternationalAssetsPage:
|
|
36
|
+
params: dict[str, Any] = {
|
|
37
|
+
"exchangeName": exchange,
|
|
38
|
+
"search": search,
|
|
39
|
+
"page": page,
|
|
40
|
+
"pageSize": page_size,
|
|
41
|
+
}
|
|
42
|
+
response = api_request("GET", "/frontoffice/shared/Asset", params=params)
|
|
43
|
+
if response.status_code >= 400:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"Failed to list international assets "
|
|
46
|
+
f"({response.status_code} {response.reason_phrase}): {response.text}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
raw: object = response.json()
|
|
50
|
+
if not isinstance(raw, dict):
|
|
51
|
+
raise RuntimeError("International assets response was not a JSON object.")
|
|
52
|
+
payload = cast(dict[str, object], raw)
|
|
53
|
+
|
|
54
|
+
raw_items = payload.get("items")
|
|
55
|
+
raw_total = payload.get("totalCount")
|
|
56
|
+
if not isinstance(raw_items, list):
|
|
57
|
+
raise RuntimeError("International assets response missing 'items' array.")
|
|
58
|
+
if not isinstance(raw_total, int):
|
|
59
|
+
raise RuntimeError("International assets response missing integer 'totalCount'.")
|
|
60
|
+
|
|
61
|
+
items = tuple(
|
|
62
|
+
_to_asset(cast(dict[str, object], item))
|
|
63
|
+
for item in cast(list[object], raw_items)
|
|
64
|
+
if isinstance(item, dict)
|
|
65
|
+
)
|
|
66
|
+
return InternationalAssetsPage(items=items, total=raw_total)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _to_asset(item: dict[str, object]) -> InternationalAsset:
|
|
70
|
+
return InternationalAsset(
|
|
71
|
+
asset_id=_as_str(item.get("id")),
|
|
72
|
+
symbol=_as_str(item.get("symbol")),
|
|
73
|
+
name=_as_str(item.get("name")),
|
|
74
|
+
exchange=_as_str(item.get("exchangeName")),
|
|
75
|
+
asset_class=_as_str(item.get("assetClassName")),
|
|
76
|
+
status=_as_str(item.get("status")),
|
|
77
|
+
tradable=_as_bool(item.get("tradable")),
|
|
78
|
+
shortable=_as_bool(item.get("shortable")),
|
|
79
|
+
fractionable=_as_bool(item.get("fractionable")),
|
|
80
|
+
volume=_as_int(item.get("volume")),
|
|
81
|
+
trade_count=_as_int(item.get("tradeCount")),
|
|
82
|
+
cusip=_as_optional_str(item.get("cusip")),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _as_str(value: object) -> str:
|
|
87
|
+
return value.strip() if isinstance(value, str) else ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _as_optional_str(value: object) -> str | None:
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
cleaned = value.strip()
|
|
93
|
+
return cleaned or None
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _as_int(value: object) -> int:
|
|
98
|
+
return value if isinstance(value, int) else 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _as_bool(value: object) -> bool:
|
|
102
|
+
return bool(value) if isinstance(value, bool) else False
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
|
|
4
|
+
from nemo_cli.api.client import api_request
|
|
5
|
+
|
|
6
|
+
DEFAULT_SUBCLASSES: tuple[str, ...] = ("ACC", "ACC_INT", "CFI", "ETF", "OPC")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class LocalInstrument:
|
|
11
|
+
id_instrumento: int
|
|
12
|
+
nemotecnico: str
|
|
13
|
+
descripcion: str
|
|
14
|
+
cod_sub_clase: str
|
|
15
|
+
cod_clase: str
|
|
16
|
+
codigo_familia: str
|
|
17
|
+
cod_moneda: str
|
|
18
|
+
cod_pais: str
|
|
19
|
+
isin: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class LocalInstrumentsPage:
|
|
24
|
+
items: tuple[LocalInstrument, ...]
|
|
25
|
+
total: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def list_local_instruments(
|
|
29
|
+
*,
|
|
30
|
+
search: str = "",
|
|
31
|
+
subclasses: tuple[str, ...] = DEFAULT_SUBCLASSES,
|
|
32
|
+
page: int = 1,
|
|
33
|
+
limit: int = 30,
|
|
34
|
+
) -> LocalInstrumentsPage:
|
|
35
|
+
params: dict[str, Any] = {
|
|
36
|
+
"page": page,
|
|
37
|
+
"limit": limit,
|
|
38
|
+
"filterNemotecnico": search,
|
|
39
|
+
"codSubClaseInstrumentos": ",".join(subclasses),
|
|
40
|
+
}
|
|
41
|
+
response = api_request("GET", "/shared/Instrumentos/FiltrarInstrumentos", params=params)
|
|
42
|
+
if response.status_code >= 400:
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
f"Failed to list local instruments "
|
|
45
|
+
f"({response.status_code} {response.reason_phrase}): {response.text}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
raw: object = response.json()
|
|
49
|
+
if not isinstance(raw, dict):
|
|
50
|
+
raise RuntimeError("Local instruments response was not a JSON object.")
|
|
51
|
+
payload = cast(dict[str, object], raw)
|
|
52
|
+
|
|
53
|
+
raw_items = payload.get("result")
|
|
54
|
+
raw_total = payload.get("total")
|
|
55
|
+
if not isinstance(raw_items, list):
|
|
56
|
+
raise RuntimeError("Local instruments response missing 'result' array.")
|
|
57
|
+
if not isinstance(raw_total, int):
|
|
58
|
+
raise RuntimeError("Local instruments response missing integer 'total'.")
|
|
59
|
+
|
|
60
|
+
items = tuple(
|
|
61
|
+
_to_instrument(cast(dict[str, object], item))
|
|
62
|
+
for item in cast(list[object], raw_items)
|
|
63
|
+
if isinstance(item, dict)
|
|
64
|
+
)
|
|
65
|
+
return LocalInstrumentsPage(items=items, total=raw_total)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _to_instrument(item: dict[str, object]) -> LocalInstrument:
|
|
69
|
+
return LocalInstrument(
|
|
70
|
+
id_instrumento=_as_int(item.get("idInstrumento")),
|
|
71
|
+
nemotecnico=_as_str(item.get("nemotecnico")),
|
|
72
|
+
descripcion=_as_str(item.get("dscInstrumento")),
|
|
73
|
+
cod_sub_clase=_as_str(item.get("codSubClaseInstrumento")),
|
|
74
|
+
cod_clase=_as_str(item.get("codClaseInstrumento")),
|
|
75
|
+
codigo_familia=_as_str(item.get("codigoFamilia")),
|
|
76
|
+
cod_moneda=_as_str(item.get("codMoneda")),
|
|
77
|
+
cod_pais=_as_str(item.get("codPais")),
|
|
78
|
+
isin=_as_optional_str(item.get("isin")),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _as_str(value: object) -> str:
|
|
83
|
+
return value.strip() if isinstance(value, str) else ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _as_optional_str(value: object) -> str | None:
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
cleaned = value.strip()
|
|
89
|
+
return cleaned or None
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _as_int(value: object) -> int:
|
|
94
|
+
return value if isinstance(value, int) else 0
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import statistics
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from nemo_cli.api.client import api_request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class PricePoint:
|
|
10
|
+
date: str
|
|
11
|
+
price: float
|
|
12
|
+
updated_at: str | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PriceHistoryStats:
|
|
17
|
+
first_date: str
|
|
18
|
+
last_date: str
|
|
19
|
+
first_price: float
|
|
20
|
+
last_price: float
|
|
21
|
+
min_price: float
|
|
22
|
+
min_date: str
|
|
23
|
+
max_price: float
|
|
24
|
+
max_date: str
|
|
25
|
+
mean_price: float
|
|
26
|
+
total_return_pct: float
|
|
27
|
+
daily_return_std_pct: float
|
|
28
|
+
days: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PriceHistory:
|
|
33
|
+
instrument_id: int
|
|
34
|
+
points: tuple[PricePoint, ...]
|
|
35
|
+
stats: PriceHistoryStats | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_price_history(instrument_id: int) -> PriceHistory:
|
|
39
|
+
response = api_request(
|
|
40
|
+
"GET",
|
|
41
|
+
"/frontoffice/shared/PublicadorPrecio/GetPreciosInstrumento",
|
|
42
|
+
params={"idInstrumento": instrument_id},
|
|
43
|
+
)
|
|
44
|
+
if response.status_code >= 400:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
f"Failed to load price history "
|
|
47
|
+
f"({response.status_code} {response.reason_phrase}): {response.text}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
raw: object = response.json()
|
|
51
|
+
if not isinstance(raw, list):
|
|
52
|
+
raise RuntimeError("Price history response was not a JSON array.")
|
|
53
|
+
|
|
54
|
+
points = tuple(
|
|
55
|
+
sorted(
|
|
56
|
+
(
|
|
57
|
+
_to_point(cast(dict[str, object], item))
|
|
58
|
+
for item in cast(list[object], raw)
|
|
59
|
+
if isinstance(item, dict)
|
|
60
|
+
),
|
|
61
|
+
key=lambda p: p.date,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
stats = _compute_stats(points)
|
|
65
|
+
|
|
66
|
+
return PriceHistory(
|
|
67
|
+
instrument_id=instrument_id,
|
|
68
|
+
points=points,
|
|
69
|
+
stats=stats,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _to_point(item: dict[str, object]) -> PricePoint:
|
|
74
|
+
return PricePoint(
|
|
75
|
+
date=_as_date(item.get("fecha")),
|
|
76
|
+
price=_as_float(item.get("precio")),
|
|
77
|
+
updated_at=_as_optional_str(item.get("fechaActualizacion")),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _compute_stats(points: tuple[PricePoint, ...]) -> PriceHistoryStats | None:
|
|
82
|
+
if not points:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
prices = [p.price for p in points]
|
|
86
|
+
first_point, last_point = points[0], points[-1]
|
|
87
|
+
|
|
88
|
+
min_idx = min(range(len(prices)), key=lambda i: prices[i])
|
|
89
|
+
max_idx = max(range(len(prices)), key=lambda i: prices[i])
|
|
90
|
+
|
|
91
|
+
total_return = (
|
|
92
|
+
(last_point.price - first_point.price) / first_point.price
|
|
93
|
+
if first_point.price > 0
|
|
94
|
+
else 0.0
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if len(points) >= 2:
|
|
98
|
+
daily_returns = [
|
|
99
|
+
(prices[i] - prices[i - 1]) / prices[i - 1]
|
|
100
|
+
for i in range(1, len(prices))
|
|
101
|
+
if prices[i - 1] > 0
|
|
102
|
+
]
|
|
103
|
+
daily_std = statistics.stdev(daily_returns) if len(daily_returns) >= 2 else 0.0
|
|
104
|
+
else:
|
|
105
|
+
daily_std = 0.0
|
|
106
|
+
|
|
107
|
+
return PriceHistoryStats(
|
|
108
|
+
first_date=first_point.date,
|
|
109
|
+
last_date=last_point.date,
|
|
110
|
+
first_price=first_point.price,
|
|
111
|
+
last_price=last_point.price,
|
|
112
|
+
min_price=points[min_idx].price,
|
|
113
|
+
min_date=points[min_idx].date,
|
|
114
|
+
max_price=points[max_idx].price,
|
|
115
|
+
max_date=points[max_idx].date,
|
|
116
|
+
mean_price=statistics.fmean(prices),
|
|
117
|
+
total_return_pct=total_return,
|
|
118
|
+
daily_return_std_pct=daily_std,
|
|
119
|
+
days=len(points),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _as_date(value: object) -> str:
|
|
124
|
+
if isinstance(value, str) and len(value) >= 10:
|
|
125
|
+
return value[:10]
|
|
126
|
+
return value if isinstance(value, str) else ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _as_optional_str(value: object) -> str | None:
|
|
130
|
+
if isinstance(value, str):
|
|
131
|
+
cleaned = value.strip()
|
|
132
|
+
return cleaned or None
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _as_float(value: object) -> float:
|
|
137
|
+
if isinstance(value, (int, float)):
|
|
138
|
+
return float(value)
|
|
139
|
+
return 0.0
|
|
File without changes
|