@heylemon/lemonade 0.2.2 → 0.2.3

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,528 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = ["yfinance>=0.2.40"]
5
+ # ///
6
+ """
7
+ Portfolio management for stock-analysis skill.
8
+
9
+ Usage:
10
+ uv run portfolio.py create "Portfolio Name"
11
+ uv run portfolio.py list
12
+ uv run portfolio.py show [--portfolio NAME]
13
+ uv run portfolio.py delete "Portfolio Name"
14
+ uv run portfolio.py rename "Old Name" "New Name"
15
+
16
+ uv run portfolio.py add TICKER --quantity 100 --cost 150.00 [--portfolio NAME]
17
+ uv run portfolio.py update TICKER --quantity 150 [--portfolio NAME]
18
+ uv run portfolio.py remove TICKER [--portfolio NAME]
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+ from dataclasses import dataclass, asdict
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Literal
29
+
30
+ import yfinance as yf
31
+
32
+
33
+ # Top 20 supported cryptocurrencies
34
+ SUPPORTED_CRYPTOS = {
35
+ "BTC-USD", "ETH-USD", "BNB-USD", "SOL-USD", "XRP-USD",
36
+ "ADA-USD", "DOGE-USD", "AVAX-USD", "DOT-USD", "MATIC-USD",
37
+ "LINK-USD", "ATOM-USD", "UNI-USD", "LTC-USD", "BCH-USD",
38
+ "XLM-USD", "ALGO-USD", "VET-USD", "FIL-USD", "NEAR-USD",
39
+ }
40
+
41
+
42
+ def get_storage_path() -> Path:
43
+ """Get the portfolio storage path."""
44
+ state_dir = os.environ.get("CLAWDBOT_STATE_DIR", os.path.expanduser("~/.clawdbot"))
45
+ portfolio_dir = Path(state_dir) / "skills" / "stock-analysis"
46
+ portfolio_dir.mkdir(parents=True, exist_ok=True)
47
+ return portfolio_dir / "portfolios.json"
48
+
49
+
50
+ def detect_asset_type(ticker: str) -> Literal["stock", "crypto"]:
51
+ """Detect asset type from ticker format."""
52
+ ticker_upper = ticker.upper()
53
+ if ticker_upper.endswith("-USD"):
54
+ base = ticker_upper[:-4]
55
+ if base.isalpha() and f"{base}-USD" in SUPPORTED_CRYPTOS:
56
+ return "crypto"
57
+ if base.isalpha():
58
+ return "crypto"
59
+ return "stock"
60
+
61
+
62
+ @dataclass
63
+ class Asset:
64
+ ticker: str
65
+ type: Literal["stock", "crypto"]
66
+ quantity: float
67
+ cost_basis: float
68
+ added_at: str
69
+
70
+
71
+ @dataclass
72
+ class Portfolio:
73
+ name: str
74
+ created_at: str
75
+ updated_at: str
76
+ assets: list[Asset]
77
+
78
+
79
+ class PortfolioStore:
80
+ """Manages portfolio storage with atomic writes."""
81
+
82
+ def __init__(self, path: Path | None = None):
83
+ self.path = path or get_storage_path()
84
+ self._data: dict | None = None
85
+
86
+ def _load(self) -> dict:
87
+ """Load portfolios from disk."""
88
+ if self._data is not None:
89
+ return self._data
90
+
91
+ if not self.path.exists():
92
+ self._data = {"version": 1, "portfolios": {}}
93
+ return self._data
94
+
95
+ try:
96
+ with open(self.path, "r", encoding="utf-8") as f:
97
+ self._data = json.load(f)
98
+ return self._data
99
+ except (json.JSONDecodeError, IOError):
100
+ self._data = {"version": 1, "portfolios": {}}
101
+ return self._data
102
+
103
+ def _save(self) -> None:
104
+ """Save portfolios to disk with atomic write."""
105
+ if self._data is None:
106
+ return
107
+
108
+ self.path.parent.mkdir(parents=True, exist_ok=True)
109
+
110
+ tmp_path = self.path.with_suffix(".tmp")
111
+ try:
112
+ with open(tmp_path, "w", encoding="utf-8") as f:
113
+ json.dump(self._data, f, indent=2)
114
+ tmp_path.replace(self.path)
115
+ except Exception:
116
+ if tmp_path.exists():
117
+ tmp_path.unlink()
118
+ raise
119
+
120
+ def _get_portfolio_key(self, name: str) -> str:
121
+ """Convert portfolio name to storage key."""
122
+ return name.lower().replace(" ", "-")
123
+
124
+ def list_portfolios(self) -> list[str]:
125
+ """List all portfolio names."""
126
+ data = self._load()
127
+ return [p["name"] for p in data["portfolios"].values()]
128
+
129
+ def get_portfolio(self, name: str) -> Portfolio | None:
130
+ """Get a portfolio by name."""
131
+ data = self._load()
132
+ key = self._get_portfolio_key(name)
133
+
134
+ if key not in data["portfolios"]:
135
+ for k, v in data["portfolios"].items():
136
+ if v["name"].lower() == name.lower():
137
+ key = k
138
+ break
139
+ else:
140
+ return None
141
+
142
+ p = data["portfolios"][key]
143
+ assets = [
144
+ Asset(
145
+ ticker=a["ticker"],
146
+ type=a["type"],
147
+ quantity=a["quantity"],
148
+ cost_basis=a["cost_basis"],
149
+ added_at=a["added_at"],
150
+ )
151
+ for a in p.get("assets", [])
152
+ ]
153
+ return Portfolio(
154
+ name=p["name"],
155
+ created_at=p["created_at"],
156
+ updated_at=p["updated_at"],
157
+ assets=assets,
158
+ )
159
+
160
+ def create_portfolio(self, name: str) -> Portfolio:
161
+ """Create a new portfolio."""
162
+ data = self._load()
163
+ key = self._get_portfolio_key(name)
164
+
165
+ if key in data["portfolios"]:
166
+ raise ValueError(f"Portfolio '{name}' already exists")
167
+
168
+ now = datetime.now().isoformat()
169
+ portfolio = {
170
+ "name": name,
171
+ "created_at": now,
172
+ "updated_at": now,
173
+ "assets": [],
174
+ }
175
+ data["portfolios"][key] = portfolio
176
+ self._save()
177
+
178
+ return Portfolio(name=name, created_at=now, updated_at=now, assets=[])
179
+
180
+ def delete_portfolio(self, name: str) -> bool:
181
+ """Delete a portfolio."""
182
+ data = self._load()
183
+ key = self._get_portfolio_key(name)
184
+
185
+ if key not in data["portfolios"]:
186
+ for k, v in data["portfolios"].items():
187
+ if v["name"].lower() == name.lower():
188
+ key = k
189
+ break
190
+ else:
191
+ return False
192
+
193
+ del data["portfolios"][key]
194
+ self._save()
195
+ return True
196
+
197
+ def rename_portfolio(self, old_name: str, new_name: str) -> bool:
198
+ """Rename a portfolio."""
199
+ data = self._load()
200
+ old_key = self._get_portfolio_key(old_name)
201
+ new_key = self._get_portfolio_key(new_name)
202
+
203
+ if old_key not in data["portfolios"]:
204
+ for k, v in data["portfolios"].items():
205
+ if v["name"].lower() == old_name.lower():
206
+ old_key = k
207
+ break
208
+ else:
209
+ return False
210
+
211
+ if new_key in data["portfolios"] and new_key != old_key:
212
+ raise ValueError(f"Portfolio '{new_name}' already exists")
213
+
214
+ portfolio = data["portfolios"].pop(old_key)
215
+ portfolio["name"] = new_name
216
+ portfolio["updated_at"] = datetime.now().isoformat()
217
+ data["portfolios"][new_key] = portfolio
218
+ self._save()
219
+ return True
220
+
221
+ def add_asset(
222
+ self,
223
+ portfolio_name: str,
224
+ ticker: str,
225
+ quantity: float,
226
+ cost_basis: float,
227
+ ) -> Asset:
228
+ """Add an asset to a portfolio."""
229
+ data = self._load()
230
+ key = self._get_portfolio_key(portfolio_name)
231
+
232
+ if key not in data["portfolios"]:
233
+ for k, v in data["portfolios"].items():
234
+ if v["name"].lower() == portfolio_name.lower():
235
+ key = k
236
+ break
237
+ else:
238
+ raise ValueError(f"Portfolio '{portfolio_name}' not found")
239
+
240
+ portfolio = data["portfolios"][key]
241
+ ticker = ticker.upper()
242
+
243
+ for asset in portfolio["assets"]:
244
+ if asset["ticker"] == ticker:
245
+ raise ValueError(f"Asset '{ticker}' already in portfolio. Use 'update' to modify.")
246
+
247
+ asset_type = detect_asset_type(ticker)
248
+ try:
249
+ stock = yf.Ticker(ticker)
250
+ info = stock.info
251
+ if "regularMarketPrice" not in info:
252
+ raise ValueError(f"Invalid ticker: {ticker}")
253
+ except Exception as e:
254
+ raise ValueError(f"Could not validate ticker '{ticker}': {e}")
255
+
256
+ now = datetime.now().isoformat()
257
+ asset = {
258
+ "ticker": ticker,
259
+ "type": asset_type,
260
+ "quantity": quantity,
261
+ "cost_basis": cost_basis,
262
+ "added_at": now,
263
+ }
264
+ portfolio["assets"].append(asset)
265
+ portfolio["updated_at"] = now
266
+ self._save()
267
+
268
+ return Asset(**asset)
269
+
270
+ def update_asset(
271
+ self,
272
+ portfolio_name: str,
273
+ ticker: str,
274
+ quantity: float | None = None,
275
+ cost_basis: float | None = None,
276
+ ) -> Asset | None:
277
+ """Update an asset in a portfolio."""
278
+ data = self._load()
279
+ key = self._get_portfolio_key(portfolio_name)
280
+
281
+ if key not in data["portfolios"]:
282
+ for k, v in data["portfolios"].items():
283
+ if v["name"].lower() == portfolio_name.lower():
284
+ key = k
285
+ break
286
+ else:
287
+ return None
288
+
289
+ portfolio = data["portfolios"][key]
290
+ ticker = ticker.upper()
291
+
292
+ for asset in portfolio["assets"]:
293
+ if asset["ticker"] == ticker:
294
+ if quantity is not None:
295
+ asset["quantity"] = quantity
296
+ if cost_basis is not None:
297
+ asset["cost_basis"] = cost_basis
298
+ portfolio["updated_at"] = datetime.now().isoformat()
299
+ self._save()
300
+ return Asset(**asset)
301
+
302
+ return None
303
+
304
+ def remove_asset(self, portfolio_name: str, ticker: str) -> bool:
305
+ """Remove an asset from a portfolio."""
306
+ data = self._load()
307
+ key = self._get_portfolio_key(portfolio_name)
308
+
309
+ if key not in data["portfolios"]:
310
+ for k, v in data["portfolios"].items():
311
+ if v["name"].lower() == portfolio_name.lower():
312
+ key = k
313
+ break
314
+ else:
315
+ return False
316
+
317
+ portfolio = data["portfolios"][key]
318
+ ticker = ticker.upper()
319
+
320
+ original_len = len(portfolio["assets"])
321
+ portfolio["assets"] = [a for a in portfolio["assets"] if a["ticker"] != ticker]
322
+
323
+ if len(portfolio["assets"]) < original_len:
324
+ portfolio["updated_at"] = datetime.now().isoformat()
325
+ self._save()
326
+ return True
327
+
328
+ return False
329
+
330
+ def get_default_portfolio_name(self) -> str | None:
331
+ """Get the default (first) portfolio name, or None if empty."""
332
+ portfolios = self.list_portfolios()
333
+ return portfolios[0] if portfolios else None
334
+
335
+
336
+ def format_currency(value: float) -> str:
337
+ """Format a value as currency."""
338
+ if abs(value) >= 1_000_000:
339
+ return f"${value/1_000_000:.2f}M"
340
+ elif abs(value) >= 1_000:
341
+ return f"${value/1_000:.2f}K"
342
+ else:
343
+ return f"${value:.2f}"
344
+
345
+
346
+ def show_portfolio(portfolio: Portfolio, verbose: bool = False) -> None:
347
+ """Display portfolio details with current prices."""
348
+ print(f"\n{'='*60}")
349
+ print(f"PORTFOLIO: {portfolio.name}")
350
+ print(f"Created: {portfolio.created_at[:10]} | Updated: {portfolio.updated_at[:10]}")
351
+ print(f"{'='*60}\n")
352
+
353
+ if not portfolio.assets:
354
+ print(" No assets in portfolio. Use 'add' to add assets.\n")
355
+ return
356
+
357
+ total_cost = 0.0
358
+ total_value = 0.0
359
+
360
+ print(f"{'Ticker':<12} {'Type':<8} {'Qty':>10} {'Cost':>12} {'Current':>12} {'Value':>14} {'P&L':>12}")
361
+ print("-" * 82)
362
+
363
+ for asset in portfolio.assets:
364
+ try:
365
+ stock = yf.Ticker(asset.ticker)
366
+ current_price = stock.info.get("regularMarketPrice", 0) or 0
367
+ except Exception:
368
+ current_price = 0
369
+
370
+ cost_total = asset.quantity * asset.cost_basis
371
+ current_value = asset.quantity * current_price
372
+ pnl = current_value - cost_total
373
+ pnl_pct = (pnl / cost_total * 100) if cost_total > 0 else 0
374
+
375
+ total_cost += cost_total
376
+ total_value += current_value
377
+
378
+ pnl_str = f"{'+' if pnl >= 0 else ''}{format_currency(pnl)} ({pnl_pct:+.1f}%)"
379
+
380
+ print(f"{asset.ticker:<12} {asset.type:<8} {asset.quantity:>10.4f} "
381
+ f"{format_currency(asset.cost_basis):>12} {format_currency(current_price):>12} "
382
+ f"{format_currency(current_value):>14} {pnl_str:>12}")
383
+
384
+ print("-" * 82)
385
+ total_pnl = total_value - total_cost
386
+ total_pnl_pct = (total_pnl / total_cost * 100) if total_cost > 0 else 0
387
+ print(f"{'TOTAL':<12} {'':<8} {'':<10} {format_currency(total_cost):>12} {'':<12} "
388
+ f"{format_currency(total_value):>14} {'+' if total_pnl >= 0 else ''}{format_currency(total_pnl)} ({total_pnl_pct:+.1f}%)")
389
+ print()
390
+
391
+
392
+ def main():
393
+ parser = argparse.ArgumentParser(description="Portfolio management for stock-analysis")
394
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
395
+
396
+ create_parser = subparsers.add_parser("create", help="Create a new portfolio")
397
+ create_parser.add_argument("name", help="Portfolio name")
398
+
399
+ subparsers.add_parser("list", help="List all portfolios")
400
+
401
+ show_parser = subparsers.add_parser("show", help="Show portfolio details")
402
+ show_parser.add_argument("--portfolio", "-p", help="Portfolio name (default: first portfolio)")
403
+
404
+ delete_parser = subparsers.add_parser("delete", help="Delete a portfolio")
405
+ delete_parser.add_argument("name", help="Portfolio name")
406
+
407
+ rename_parser = subparsers.add_parser("rename", help="Rename a portfolio")
408
+ rename_parser.add_argument("old_name", help="Current portfolio name")
409
+ rename_parser.add_argument("new_name", help="New portfolio name")
410
+
411
+ add_parser = subparsers.add_parser("add", help="Add an asset to portfolio")
412
+ add_parser.add_argument("ticker", help="Stock/crypto ticker (e.g., AAPL, BTC-USD)")
413
+ add_parser.add_argument("--quantity", "-q", type=float, required=True, help="Quantity")
414
+ add_parser.add_argument("--cost", "-c", type=float, required=True, help="Cost basis per unit")
415
+ add_parser.add_argument("--portfolio", "-p", help="Portfolio name (default: first portfolio)")
416
+
417
+ update_parser = subparsers.add_parser("update", help="Update an asset in portfolio")
418
+ update_parser.add_argument("ticker", help="Stock/crypto ticker")
419
+ update_parser.add_argument("--quantity", "-q", type=float, help="New quantity")
420
+ update_parser.add_argument("--cost", "-c", type=float, help="New cost basis per unit")
421
+ update_parser.add_argument("--portfolio", "-p", help="Portfolio name (default: first portfolio)")
422
+
423
+ remove_parser = subparsers.add_parser("remove", help="Remove an asset from portfolio")
424
+ remove_parser.add_argument("ticker", help="Stock/crypto ticker")
425
+ remove_parser.add_argument("--portfolio", "-p", help="Portfolio name (default: first portfolio)")
426
+
427
+ args = parser.parse_args()
428
+
429
+ if not args.command:
430
+ parser.print_help()
431
+ sys.exit(1)
432
+
433
+ store = PortfolioStore()
434
+
435
+ try:
436
+ if args.command == "create":
437
+ portfolio = store.create_portfolio(args.name)
438
+ print(f"Created portfolio: {portfolio.name}")
439
+
440
+ elif args.command == "list":
441
+ portfolios = store.list_portfolios()
442
+ if not portfolios:
443
+ print("No portfolios found. Use 'create' to create one.")
444
+ else:
445
+ print("\nPortfolios:")
446
+ for name in portfolios:
447
+ p = store.get_portfolio(name)
448
+ asset_count = len(p.assets) if p else 0
449
+ print(f" - {name} ({asset_count} assets)")
450
+ print()
451
+
452
+ elif args.command == "show":
453
+ portfolio_name = args.portfolio or store.get_default_portfolio_name()
454
+ if not portfolio_name:
455
+ print("No portfolios found. Use 'create' to create one.")
456
+ sys.exit(1)
457
+
458
+ portfolio = store.get_portfolio(portfolio_name)
459
+ if not portfolio:
460
+ print(f"Portfolio '{portfolio_name}' not found.")
461
+ sys.exit(1)
462
+
463
+ show_portfolio(portfolio)
464
+
465
+ elif args.command == "delete":
466
+ if store.delete_portfolio(args.name):
467
+ print(f"Deleted portfolio: {args.name}")
468
+ else:
469
+ print(f"Portfolio '{args.name}' not found.")
470
+ sys.exit(1)
471
+
472
+ elif args.command == "rename":
473
+ if store.rename_portfolio(args.old_name, args.new_name):
474
+ print(f"Renamed portfolio: {args.old_name} -> {args.new_name}")
475
+ else:
476
+ print(f"Portfolio '{args.old_name}' not found.")
477
+ sys.exit(1)
478
+
479
+ elif args.command == "add":
480
+ portfolio_name = args.portfolio or store.get_default_portfolio_name()
481
+ if not portfolio_name:
482
+ print("No portfolios found. Use 'create' to create one first.")
483
+ sys.exit(1)
484
+
485
+ asset = store.add_asset(portfolio_name, args.ticker, args.quantity, args.cost)
486
+ print(f"Added {asset.ticker} ({asset.type}) to {portfolio_name}: "
487
+ f"{asset.quantity} units @ {format_currency(asset.cost_basis)}")
488
+
489
+ elif args.command == "update":
490
+ portfolio_name = args.portfolio or store.get_default_portfolio_name()
491
+ if not portfolio_name:
492
+ print("No portfolios found.")
493
+ sys.exit(1)
494
+
495
+ if args.quantity is None and args.cost is None:
496
+ print("Must specify --quantity and/or --cost to update.")
497
+ sys.exit(1)
498
+
499
+ asset = store.update_asset(portfolio_name, args.ticker, args.quantity, args.cost)
500
+ if asset:
501
+ print(f"Updated {asset.ticker} in {portfolio_name}: "
502
+ f"{asset.quantity} units @ {format_currency(asset.cost_basis)}")
503
+ else:
504
+ print(f"Asset '{args.ticker}' not found in portfolio '{portfolio_name}'.")
505
+ sys.exit(1)
506
+
507
+ elif args.command == "remove":
508
+ portfolio_name = args.portfolio or store.get_default_portfolio_name()
509
+ if not portfolio_name:
510
+ print("No portfolios found.")
511
+ sys.exit(1)
512
+
513
+ if store.remove_asset(portfolio_name, args.ticker):
514
+ print(f"Removed {args.ticker.upper()} from {portfolio_name}")
515
+ else:
516
+ print(f"Asset '{args.ticker}' not found in portfolio '{portfolio_name}'.")
517
+ sys.exit(1)
518
+
519
+ except ValueError as e:
520
+ print(f"Error: {e}")
521
+ sys.exit(1)
522
+ except Exception as e:
523
+ print(f"Unexpected error: {e}")
524
+ sys.exit(1)
525
+
526
+
527
+ if __name__ == "__main__":
528
+ main()