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
nemo_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
nemo_cli/__main__.py
ADDED
nemo_cli/api/__init__.py
ADDED
|
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)
|
nemo_cli/auth/service.py
ADDED
|
@@ -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)
|