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,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