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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
nemo_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from nemo_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
File without changes
nemo_cli/api/client.py ADDED
@@ -0,0 +1,81 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from nemo_cli.auth.jwt import is_expiring_within
6
+ from nemo_cli.auth.service import refresh_token, sign_in
7
+ from nemo_cli.auth.token_store import clear_token, get_token, set_token
8
+ from nemo_cli.config import API_BASE_URL
9
+
10
+ # How close to expiry (in seconds) we proactively renew the token before
11
+ # sending the next request. ADR-012 picks 60s as a balance between racing
12
+ # the actual expiry and refreshing too eagerly on short CLI invocations.
13
+ _PROACTIVE_REFRESH_SECONDS = 60
14
+
15
+
16
+ def _ensure_token() -> str:
17
+ """Return a usable bearer token, refreshing proactively if it is close
18
+ to expiry. Bootstraps via SignIn if no token is cached.
19
+ """
20
+ cached = get_token()
21
+ if cached is None:
22
+ return _bootstrap_signin()
23
+
24
+ if is_expiring_within(cached, _PROACTIVE_REFRESH_SECONDS):
25
+ # Cached token is about to expire — renew before using it.
26
+ try:
27
+ renewed = refresh_token(cached)
28
+ except RuntimeError:
29
+ # The server rejected our refresh (token is too stale or
30
+ # otherwise invalid). Fall back to a fresh SignIn.
31
+ return _bootstrap_signin()
32
+ set_token(renewed)
33
+ return renewed
34
+
35
+ return cached
36
+
37
+
38
+ def _bootstrap_signin() -> str:
39
+ """Clear any stale cache and obtain a fresh token via SignIn."""
40
+ clear_token()
41
+ fresh = sign_in()
42
+ set_token(fresh)
43
+ return fresh
44
+
45
+
46
+ def api_request(
47
+ method: str,
48
+ path: str,
49
+ *,
50
+ json: Any | None = None,
51
+ params: dict[str, Any] | None = None,
52
+ timeout: float = 15.0,
53
+ ) -> httpx.Response:
54
+ url = f"{API_BASE_URL}{path}"
55
+
56
+ def send(token: str) -> httpx.Response:
57
+ return httpx.request(
58
+ method,
59
+ url,
60
+ json=json,
61
+ params=params,
62
+ timeout=timeout,
63
+ headers={"Authorization": f"Bearer {token}"},
64
+ )
65
+
66
+ token = _ensure_token()
67
+ response = send(token)
68
+
69
+ if response.status_code == 401:
70
+ # The proactive check missed (server-side revocation, clock
71
+ # skew, schema change). Try Refresh first; only re-SignIn if
72
+ # Refresh itself fails. ADR-012.
73
+ try:
74
+ renewed = refresh_token(token)
75
+ set_token(renewed)
76
+ response = send(renewed)
77
+ except RuntimeError:
78
+ fresh = _bootstrap_signin()
79
+ response = send(fresh)
80
+
81
+ return response
File without changes
nemo_cli/auth/jwt.py ADDED
@@ -0,0 +1,47 @@
1
+ """Read-only JWT introspection helpers.
2
+
3
+ Used to time refresh calls (peek at the `exp` claim). Signature
4
+ verification is intentionally skipped — the server is the authority on
5
+ whether a token is valid; we just want a hint for when to renew.
6
+ """
7
+
8
+ import base64
9
+ import binascii
10
+ import json
11
+ import time
12
+ from typing import cast
13
+
14
+
15
+ def is_expiring_within(token: str, seconds: int) -> bool:
16
+ """True if the JWT's `exp` claim is at most `seconds` away from now.
17
+
18
+ Already-expired tokens (exp <= now) return True. Malformed tokens
19
+ return False — the caller treats them as fresh and lets the
20
+ reactive 401 path handle any actual rejection.
21
+ """
22
+ payload = _decode_payload(token)
23
+ if payload is None:
24
+ return False
25
+ exp = payload.get("exp")
26
+ if not isinstance(exp, (int, float)):
27
+ return False
28
+ return exp - time.time() <= seconds
29
+
30
+
31
+ def _decode_payload(token: str) -> dict[str, object] | None:
32
+ parts = token.split(".")
33
+ if len(parts) != 3:
34
+ return None
35
+ payload_b64 = parts[1]
36
+ padded = payload_b64 + "=" * (-len(payload_b64) % 4)
37
+ try:
38
+ decoded = base64.urlsafe_b64decode(padded)
39
+ except (ValueError, binascii.Error):
40
+ return None
41
+ try:
42
+ parsed = json.loads(decoded)
43
+ except (json.JSONDecodeError, UnicodeDecodeError):
44
+ return None
45
+ if not isinstance(parsed, dict):
46
+ return None
47
+ return cast(dict[str, object], parsed)
@@ -0,0 +1,56 @@
1
+ from typing import cast
2
+
3
+ import httpx
4
+
5
+ from nemo_cli.config import API_BASE_URL, load_credentials
6
+
7
+
8
+ def sign_in() -> str:
9
+ credentials = load_credentials()
10
+ url = f"{API_BASE_URL}/publicapi/shared/auth/SignIn"
11
+ response = httpx.post(
12
+ url,
13
+ json={"userName": credentials.user_name, "password": credentials.password},
14
+ timeout=15.0,
15
+ )
16
+ if response.status_code >= 400:
17
+ raise RuntimeError(
18
+ f"SignIn failed ({response.status_code} {response.reason_phrase}): {response.text}"
19
+ )
20
+
21
+ raw: object = response.json()
22
+ if not isinstance(raw, dict):
23
+ raise RuntimeError("SignIn response was not a JSON object.")
24
+
25
+ payload = cast(dict[str, object], raw)
26
+ token = payload.get("token")
27
+ if not isinstance(token, str) or not token:
28
+ raise RuntimeError("SignIn response missing top-level 'token' string.")
29
+ return token
30
+
31
+
32
+ def refresh_token(current_token: str) -> str:
33
+ """Trade an existing bearer token for a freshly minted one.
34
+
35
+ Calls `GET /api/publicapi/shared/auth/RefreshToken` with the current
36
+ token in the Authorization header. The response body is a
37
+ JSON-encoded string containing the new JWT. Raises RuntimeError on
38
+ any non-2xx or malformed payload — the caller is expected to fall
39
+ back to `sign_in()` with credentials.
40
+ """
41
+ url = f"{API_BASE_URL}/publicapi/shared/auth/RefreshToken"
42
+ response = httpx.get(
43
+ url,
44
+ headers={"Authorization": f"Bearer {current_token}"},
45
+ timeout=15.0,
46
+ )
47
+ if response.status_code >= 400:
48
+ raise RuntimeError(
49
+ f"RefreshToken failed "
50
+ f"({response.status_code} {response.reason_phrase}): {response.text}"
51
+ )
52
+
53
+ payload: object = response.json()
54
+ if not isinstance(payload, str) or not payload:
55
+ raise RuntimeError("RefreshToken response was not a non-empty string.")
56
+ return payload
@@ -0,0 +1,37 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import cast
4
+
5
+ from platformdirs import user_config_path
6
+
7
+ _APP_NAME = "nemo-cli"
8
+
9
+
10
+ def _config_file() -> Path:
11
+ return user_config_path(_APP_NAME, ensure_exists=True) / "token.json"
12
+
13
+
14
+ def get_token() -> str | None:
15
+ path = _config_file()
16
+ if not path.exists():
17
+ return None
18
+ try:
19
+ raw: object = json.loads(path.read_text(encoding="utf-8"))
20
+ except (OSError, json.JSONDecodeError):
21
+ return None
22
+ if not isinstance(raw, dict):
23
+ return None
24
+ data = cast(dict[str, object], raw)
25
+ token = data.get("token")
26
+ return token if isinstance(token, str) and token else None
27
+
28
+
29
+ def set_token(token: str) -> None:
30
+ path = _config_file()
31
+ path.write_text(json.dumps({"token": token}), encoding="utf-8")
32
+
33
+
34
+ def clear_token() -> None:
35
+ path = _config_file()
36
+ if path.exists():
37
+ path.unlink()
nemo_cli/cli.py ADDED
@@ -0,0 +1,46 @@
1
+ import typer
2
+
3
+ from nemo_cli import __version__
4
+ from nemo_cli.commands.instruments import app as instruments_app
5
+ from nemo_cli.commands.login import login
6
+ from nemo_cli.commands.logout import logout
7
+ from nemo_cli.commands.portfolio import app as portfolio_app
8
+ from nemo_cli.commands.whoami import whoami
9
+
10
+ app = typer.Typer(
11
+ name="nemo",
12
+ help="Personal CLI for Chilean stockbroker portals.",
13
+ no_args_is_help=True,
14
+ add_completion=False,
15
+ )
16
+
17
+
18
+ def _version_callback(value: bool) -> None:
19
+ if value:
20
+ typer.echo(f"nemo {__version__}")
21
+ raise typer.Exit()
22
+
23
+
24
+ @app.callback()
25
+ def main_callback(
26
+ version: bool = typer.Option( # noqa: ARG001 — consumed via callback
27
+ False,
28
+ "--version",
29
+ "-V",
30
+ help="Show the CLI version and exit.",
31
+ callback=_version_callback,
32
+ is_eager=True,
33
+ ),
34
+ ) -> None:
35
+ """Personal CLI for Chilean stockbroker portals."""
36
+
37
+
38
+ app.command()(login)
39
+ app.command()(logout)
40
+ app.command()(whoami)
41
+ app.add_typer(instruments_app, name="instruments")
42
+ app.add_typer(portfolio_app, name="portfolio")
43
+
44
+
45
+ def main() -> None:
46
+ app()
File without changes
@@ -0,0 +1,271 @@
1
+ import json
2
+ from dataclasses import asdict
3
+
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from nemo_cli.instruments.international import (
9
+ InternationalAssetsPage,
10
+ list_international_assets,
11
+ )
12
+ from nemo_cli.instruments.local import (
13
+ DEFAULT_SUBCLASSES,
14
+ LocalInstrumentsPage,
15
+ list_local_instruments,
16
+ )
17
+ from nemo_cli.instruments.prices import (
18
+ PriceHistory,
19
+ get_price_history,
20
+ )
21
+
22
+ app = typer.Typer(
23
+ help="Browse instruments — local Chilean and international markets.",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+
28
+ @app.command("local")
29
+ def local(
30
+ search: str = typer.Option("", "--search", "-s", help="Substring filter on nemotecnico."),
31
+ classes: str = typer.Option(
32
+ ",".join(DEFAULT_SUBCLASSES),
33
+ "--classes",
34
+ help="Comma-separated subclasses (ACC, ACC_INT, CFI, ETF, OPC, …).",
35
+ ),
36
+ page: int = typer.Option(1, "--page", min=1),
37
+ limit: int = typer.Option(30, "--limit", min=1, max=200),
38
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON instead of a table."),
39
+ ) -> None:
40
+ """List local Chilean instruments via the FiltrarInstrumentos endpoint."""
41
+ subclasses = tuple(c.strip() for c in classes.split(",") if c.strip())
42
+ try:
43
+ page_result = list_local_instruments(
44
+ search=search,
45
+ subclasses=subclasses,
46
+ page=page,
47
+ limit=limit,
48
+ )
49
+ except Exception as error:
50
+ typer.secho(str(error), fg=typer.colors.RED, err=True)
51
+ raise typer.Exit(code=1) from error
52
+
53
+ if as_json:
54
+ _print_json_local(page_result, page=page, limit=limit)
55
+ else:
56
+ _print_table_local(page_result, page=page, limit=limit)
57
+
58
+
59
+ @app.command("international")
60
+ def international(
61
+ search: str = typer.Option("", "--search", "-s", help="Substring filter on symbol/name."),
62
+ exchange: str = typer.Option("", "--exchange", help="Restrict to an exchange (NASDAQ, NYSE)."),
63
+ page: int = typer.Option(1, "--page", min=1),
64
+ page_size: int = typer.Option(30, "--page-size", min=1, max=200),
65
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON instead of a table."),
66
+ ) -> None:
67
+ """List international (US equities) assets via the frontoffice/Asset endpoint."""
68
+ try:
69
+ page_result = list_international_assets(
70
+ search=search,
71
+ exchange=exchange,
72
+ page=page,
73
+ page_size=page_size,
74
+ )
75
+ except Exception as error:
76
+ typer.secho(str(error), fg=typer.colors.RED, err=True)
77
+ raise typer.Exit(code=1) from error
78
+
79
+ if as_json:
80
+ _print_json_international(page_result, page=page, page_size=page_size)
81
+ else:
82
+ _print_table_international(page_result, page=page, page_size=page_size)
83
+
84
+
85
+ def _print_table_local(page_result: LocalInstrumentsPage, *, page: int, limit: int) -> None:
86
+ console = Console()
87
+ table = Table(title=f"Local instruments — page {page} ({page_result.total} total)")
88
+ table.add_column("Nemotécnico", style="cyan", no_wrap=True)
89
+ table.add_column("Descripción", style="white")
90
+ table.add_column("Subclase", style="magenta")
91
+ table.add_column("Familia", style="yellow")
92
+ table.add_column("Moneda")
93
+ table.add_column("ISIN", style="dim")
94
+
95
+ for instrument in page_result.items:
96
+ table.add_row(
97
+ instrument.nemotecnico,
98
+ instrument.descripcion,
99
+ instrument.cod_sub_clase,
100
+ instrument.codigo_familia,
101
+ instrument.cod_moneda,
102
+ instrument.isin or "—",
103
+ )
104
+ console.print(table)
105
+ _print_pagination_hint(total=page_result.total, page=page, page_size=limit)
106
+
107
+
108
+ def _print_table_international(
109
+ page_result: InternationalAssetsPage,
110
+ *,
111
+ page: int,
112
+ page_size: int,
113
+ ) -> None:
114
+ console = Console()
115
+ table = Table(title=f"International assets — page {page} ({page_result.total} total)")
116
+ table.add_column("Symbol", style="cyan", no_wrap=True)
117
+ table.add_column("Name", style="white")
118
+ table.add_column("Exchange", style="magenta")
119
+ table.add_column("Class", style="yellow")
120
+ table.add_column("Tradable", justify="center")
121
+ table.add_column("Volume", justify="right")
122
+
123
+ for asset in page_result.items:
124
+ table.add_row(
125
+ asset.symbol,
126
+ asset.name,
127
+ asset.exchange,
128
+ asset.asset_class,
129
+ "✓" if asset.tradable else "✗",
130
+ f"{asset.volume:,}",
131
+ )
132
+ console.print(table)
133
+ _print_pagination_hint(total=page_result.total, page=page, page_size=page_size)
134
+
135
+
136
+ def _print_pagination_hint(*, total: int, page: int, page_size: int) -> None:
137
+ if total <= page * page_size:
138
+ return
139
+ remaining = total - page * page_size
140
+ console = Console()
141
+ console.print(
142
+ f"[dim]… {remaining} more. Use --page {page + 1} to continue.[/dim]"
143
+ )
144
+
145
+
146
+ def _print_json_local(
147
+ page_result: LocalInstrumentsPage,
148
+ *,
149
+ page: int,
150
+ limit: int,
151
+ ) -> None:
152
+ payload = {
153
+ "market": "local",
154
+ "page": page,
155
+ "page_size": limit,
156
+ "total": page_result.total,
157
+ "items": [asdict(item) for item in page_result.items],
158
+ }
159
+ typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
160
+
161
+
162
+ def _print_json_international(
163
+ page_result: InternationalAssetsPage,
164
+ *,
165
+ page: int,
166
+ page_size: int,
167
+ ) -> None:
168
+ payload = {
169
+ "market": "international",
170
+ "page": page,
171
+ "page_size": page_size,
172
+ "total": page_result.total,
173
+ "items": [asdict(item) for item in page_result.items],
174
+ }
175
+ typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
176
+
177
+
178
+ @app.command("prices")
179
+ def prices(
180
+ instrument_id: int = typer.Option(
181
+ ...,
182
+ "--id",
183
+ help=(
184
+ "Local instrument id (idInstrumento). Find it via "
185
+ "`nemo instruments local --search NEMO --json`."
186
+ ),
187
+ ),
188
+ as_json: bool = typer.Option(
189
+ False, "--json", help="Emit JSON (stats + full point series) instead of a stats panel."
190
+ ),
191
+ ) -> None:
192
+ """Show ~1 year of daily price history for a local Chilean instrument.
193
+
194
+ Note: this endpoint is **not available for international assets** —
195
+ use it only with `idInstrumento` values from `nemo instruments local`.
196
+ """
197
+ try:
198
+ history = get_price_history(instrument_id=instrument_id)
199
+ except Exception as error:
200
+ typer.secho(str(error), fg=typer.colors.RED, err=True)
201
+ raise typer.Exit(code=1) from error
202
+
203
+ if as_json:
204
+ _print_json_prices(history)
205
+ else:
206
+ _print_prices_panel(history)
207
+
208
+
209
+ def _print_prices_panel(history: PriceHistory) -> None:
210
+ console = Console()
211
+ if not history.points or history.stats is None:
212
+ console.print(
213
+ f"[yellow]No price data for instrument {history.instrument_id}. "
214
+ f"Note: this endpoint only serves local Chilean instruments.[/yellow]"
215
+ )
216
+ return
217
+
218
+ stats = history.stats
219
+ color = "green" if stats.total_return_pct >= 0 else "red"
220
+
221
+ table = Table(title=f"Price history — instrument {history.instrument_id}")
222
+ table.add_column("Field", style="bold")
223
+ table.add_column("Value", justify="right")
224
+ table.add_row("Period", f"{stats.first_date} → {stats.last_date}")
225
+ table.add_row("Days", f"{stats.days}")
226
+ table.add_row("First", f"{stats.first_price:,.2f}")
227
+ table.add_row("Last", f"{stats.last_price:,.2f}")
228
+ table.add_row(
229
+ "Total return",
230
+ f"[{color}]{stats.total_return_pct * 100:+.2f}%[/{color}]",
231
+ )
232
+ table.add_row("Min", f"{stats.min_price:,.2f} ({stats.min_date})")
233
+ table.add_row("Max", f"{stats.max_price:,.2f} ({stats.max_date})")
234
+ table.add_row("Mean", f"{stats.mean_price:,.2f}")
235
+ table.add_row(
236
+ "Daily return σ",
237
+ f"{stats.daily_return_std_pct * 100:.3f}% (not annualised)",
238
+ )
239
+ console.print(table)
240
+ console.print()
241
+
242
+ sparkline = _sparkline([p.price for p in history.points])
243
+ console.print(f"[bold]Trend:[/bold] {sparkline}")
244
+
245
+
246
+ def _sparkline(prices: list[float], width: int = 80) -> str:
247
+ if not prices:
248
+ return ""
249
+ chars = "▁▂▃▄▅▆▇█"
250
+ if len(prices) > width:
251
+ step = len(prices) / width
252
+ sampled = [prices[int(i * step)] for i in range(width)]
253
+ else:
254
+ sampled = prices
255
+ pmin, pmax = min(sampled), max(sampled)
256
+ if pmax == pmin:
257
+ return chars[3] * len(sampled)
258
+ out: list[str] = []
259
+ for value in sampled:
260
+ idx = int((value - pmin) / (pmax - pmin) * (len(chars) - 1))
261
+ out.append(chars[idx])
262
+ return "".join(out)
263
+
264
+
265
+ def _print_json_prices(history: PriceHistory) -> None:
266
+ payload: dict[str, object] = {
267
+ "instrument_id": history.instrument_id,
268
+ "stats": asdict(history.stats) if history.stats is not None else None,
269
+ "points": [asdict(p) for p in history.points],
270
+ }
271
+ typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
@@ -0,0 +1,15 @@
1
+ import typer
2
+
3
+ from nemo_cli.auth.service import sign_in
4
+ from nemo_cli.auth.token_store import set_token
5
+
6
+
7
+ def login() -> None:
8
+ """Authenticate against the configured broker portal and cache the token locally."""
9
+ try:
10
+ token = sign_in()
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
+ set_token(token)
15
+ typer.secho("Login successful. Token cached.", fg=typer.colors.GREEN)
@@ -0,0 +1,9 @@
1
+ import typer
2
+
3
+ from nemo_cli.auth.token_store import clear_token
4
+
5
+
6
+ def logout() -> None:
7
+ """Clear the cached authentication token."""
8
+ clear_token()
9
+ typer.secho("Logged out. Cached token removed.", fg=typer.colors.GREEN)