marketmyna 0.2.0__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.
- marketmyna/__init__.py +14 -0
- marketmyna/agent/__init__.py +8 -0
- marketmyna/agent/agents.py +543 -0
- marketmyna/agent/context.py +69 -0
- marketmyna/agent/dashboard.py +55 -0
- marketmyna/agent/orchestrator.py +153 -0
- marketmyna/agent/sentiment.py +150 -0
- marketmyna/agent/skills.py +119 -0
- marketmyna/ai/__init__.py +1 -0
- marketmyna/ai/portfolio_optimizer.py +330 -0
- marketmyna/analysis/__init__.py +6 -0
- marketmyna/analysis/fundamental.py +329 -0
- marketmyna/analysis/sip_optimizer.py +81 -0
- marketmyna/analysis/technical.py +321 -0
- marketmyna/api/__init__.py +1 -0
- marketmyna/api/app.py +209 -0
- marketmyna/api/deps.py +63 -0
- marketmyna/api/middlewares/__init__.py +0 -0
- marketmyna/api/middlewares/auth.py +116 -0
- marketmyna/api/middlewares/csrf.py +66 -0
- marketmyna/api/middlewares/error_handler.py +38 -0
- marketmyna/api/middlewares/slashes.py +27 -0
- marketmyna/api/v1/__init__.py +0 -0
- marketmyna/api/v1/endpoints/__init__.py +0 -0
- marketmyna/api/v1/endpoints/account.py +166 -0
- marketmyna/api/v1/endpoints/agent.py +114 -0
- marketmyna/api/v1/endpoints/bot.py +38 -0
- marketmyna/api/v1/endpoints/calendar.py +269 -0
- marketmyna/api/v1/endpoints/data_sources.py +174 -0
- marketmyna/api/v1/endpoints/health.py +10 -0
- marketmyna/api/v1/endpoints/listings.py +564 -0
- marketmyna/api/v1/endpoints/macro.py +163 -0
- marketmyna/api/v1/endpoints/portfolio.py +530 -0
- marketmyna/api/v1/endpoints/push.py +119 -0
- marketmyna/api/v1/endpoints/reports.py +49 -0
- marketmyna/api/v1/endpoints/scheduler.py +219 -0
- marketmyna/api/v1/endpoints/stocks.py +400 -0
- marketmyna/api/v1/endpoints/system_config.py +157 -0
- marketmyna/api/v1/endpoints/thesis.py +112 -0
- marketmyna/api/v1/endpoints/trade.py +709 -0
- marketmyna/api/v1/endpoints/updates.py +394 -0
- marketmyna/api/v1/endpoints/watchlists.py +179 -0
- marketmyna/api/v1/router.py +43 -0
- marketmyna/bot/__init__.py +14 -0
- marketmyna/bot/commands/__init__.py +0 -0
- marketmyna/bot/commands/analyze.py +40 -0
- marketmyna/bot/commands/ask.py +51 -0
- marketmyna/bot/commands/base.py +35 -0
- marketmyna/bot/commands/batch.py +53 -0
- marketmyna/bot/commands/common.py +84 -0
- marketmyna/bot/commands/help.py +43 -0
- marketmyna/bot/commands/markets.py +153 -0
- marketmyna/bot/commands/portfolio.py +145 -0
- marketmyna/bot/commands/research.py +323 -0
- marketmyna/bot/commands/status.py +48 -0
- marketmyna/bot/commands/trade.py +176 -0
- marketmyna/bot/dispatcher.py +95 -0
- marketmyna/bot/formatting.py +25 -0
- marketmyna/bot/models.py +27 -0
- marketmyna/bot/registry.py +50 -0
- marketmyna/bot/telegram_listener.py +227 -0
- marketmyna/cli/__init__.py +1 -0
- marketmyna/cli/scheduled_run.py +292 -0
- marketmyna/core/__init__.py +6 -0
- marketmyna/core/compliance.py +53 -0
- marketmyna/core/config.py +377 -0
- marketmyna/core/constants.py +69 -0
- marketmyna/core/home.py +22 -0
- marketmyna/core/numbers.py +28 -0
- marketmyna/core/secret_store.py +104 -0
- marketmyna/core/tenant.py +130 -0
- marketmyna/data/__init__.py +8 -0
- marketmyna/data/base.py +36 -0
- marketmyna/data/categories.py +272 -0
- marketmyna/data/china.py +360 -0
- marketmyna/data/crypto.py +200 -0
- marketmyna/data/ecb_fx.py +131 -0
- marketmyna/data/extra_sources.py +569 -0
- marketmyna/data/india.py +241 -0
- marketmyna/data/india_filings.py +343 -0
- marketmyna/data/listings.py +1314 -0
- marketmyna/data/mutual_funds.py +34 -0
- marketmyna/data/news.py +98 -0
- marketmyna/data/news_search.py +346 -0
- marketmyna/data/nse_deals.py +138 -0
- marketmyna/data/resilience.py +173 -0
- marketmyna/data/sec_facts.py +252 -0
- marketmyna/data/shariah.py +283 -0
- marketmyna/data/social/__init__.py +9 -0
- marketmyna/data/social/base.py +100 -0
- marketmyna/data/social/browser.py +92 -0
- marketmyna/data/social/hackernews.py +53 -0
- marketmyna/data/social/reddit.py +397 -0
- marketmyna/data/social/stocktwits.py +63 -0
- marketmyna/data/social/valuepickr.py +53 -0
- marketmyna/data/stocks.py +197 -0
- marketmyna/data/yahoo_price.py +48 -0
- marketmyna/launcher.py +71 -0
- marketmyna/llm/__init__.py +33 -0
- marketmyna/llm/backend_factory.py +51 -0
- marketmyna/llm/backend_registry.py +19 -0
- marketmyna/llm/channels.py +447 -0
- marketmyna/llm/json_reply.py +63 -0
- marketmyna/llm/litellm_backend.py +197 -0
- marketmyna/llm/model_catalog.py +166 -0
- marketmyna/llm/model_health.py +193 -0
- marketmyna/llm/protocol.py +111 -0
- marketmyna/llm/providers.py +128 -0
- marketmyna/llm/store.py +128 -0
- marketmyna/notifiers/__init__.py +35 -0
- marketmyna/notifiers/apprise_sender.py +43 -0
- marketmyna/notifiers/astrbot.py +47 -0
- marketmyna/notifiers/bark.py +24 -0
- marketmyna/notifiers/base.py +80 -0
- marketmyna/notifiers/dingtalk.py +54 -0
- marketmyna/notifiers/discord.py +39 -0
- marketmyna/notifiers/dispatcher.py +153 -0
- marketmyna/notifiers/email.py +99 -0
- marketmyna/notifiers/feishu.py +57 -0
- marketmyna/notifiers/googlechat.py +24 -0
- marketmyna/notifiers/gotify.py +36 -0
- marketmyna/notifiers/matrix.py +28 -0
- marketmyna/notifiers/ntfy.py +36 -0
- marketmyna/notifiers/pushbullet.py +24 -0
- marketmyna/notifiers/pushover.py +41 -0
- marketmyna/notifiers/pushplus.py +41 -0
- marketmyna/notifiers/serverchan3.py +40 -0
- marketmyna/notifiers/settings_schema.py +119 -0
- marketmyna/notifiers/signal_messenger.py +25 -0
- marketmyna/notifiers/slack.py +36 -0
- marketmyna/notifiers/teams.py +37 -0
- marketmyna/notifiers/telegram.py +81 -0
- marketmyna/notifiers/twilio.py +33 -0
- marketmyna/notifiers/webhook.py +44 -0
- marketmyna/notifiers/webpush.py +222 -0
- marketmyna/notifiers/wechat.py +40 -0
- marketmyna/notifiers/whatsapp.py +27 -0
- marketmyna/portfolio/__init__.py +6 -0
- marketmyna/portfolio/analytics.py +336 -0
- marketmyna/portfolio/manager.py +383 -0
- marketmyna/portfolio/models.py +155 -0
- marketmyna/portfolio/optimizers.py +88 -0
- marketmyna/portfolio/review.py +411 -0
- marketmyna/services/__init__.py +1 -0
- marketmyna/services/accounts.py +278 -0
- marketmyna/services/ai_access.py +94 -0
- marketmyna/services/analysis_run.py +491 -0
- marketmyna/services/big_trades.py +335 -0
- marketmyna/services/brokers/__init__.py +0 -0
- marketmyna/services/brokers/angel.py +271 -0
- marketmyna/services/brokers/finnhub.py +209 -0
- marketmyna/services/byok_detect.py +196 -0
- marketmyna/services/calendar.py +748 -0
- marketmyna/services/central_banks.py +202 -0
- marketmyna/services/commodities.py +134 -0
- marketmyna/services/compare.py +307 -0
- marketmyna/services/cot.py +148 -0
- marketmyna/services/country_macro.py +135 -0
- marketmyna/services/crude_sensitivity.py +239 -0
- marketmyna/services/crypto_mood.py +135 -0
- marketmyna/services/crypto_screens.py +226 -0
- marketmyna/services/data_export.py +91 -0
- marketmyna/services/dcf.py +220 -0
- marketmyna/services/dcf_model.py +194 -0
- marketmyna/services/delivery.py +142 -0
- marketmyna/services/discovery.py +146 -0
- marketmyna/services/dividends.py +95 -0
- marketmyna/services/earnings.py +305 -0
- marketmyna/services/econ_calendar.py +495 -0
- marketmyna/services/eia_stocks.py +115 -0
- marketmyna/services/estimates.py +109 -0
- marketmyna/services/etf.py +236 -0
- marketmyna/services/etf_rotation.py +193 -0
- marketmyna/services/etf_screens.py +196 -0
- marketmyna/services/financials.py +639 -0
- marketmyna/services/fund_ai.py +181 -0
- marketmyna/services/fund_analytics.py +371 -0
- marketmyna/services/fund_cost.py +147 -0
- marketmyna/services/fund_holdings.py +664 -0
- marketmyna/services/fund_peers.py +247 -0
- marketmyna/services/fund_rotation.py +206 -0
- marketmyna/services/fund_style.py +179 -0
- marketmyna/services/funds.py +70 -0
- marketmyna/services/hot_stocks.py +358 -0
- marketmyna/services/india_flows.py +263 -0
- marketmyna/services/india_rates.py +258 -0
- marketmyna/services/investor_lenses.py +271 -0
- marketmyna/services/leadership.py +165 -0
- marketmyna/services/live_market.py +497 -0
- marketmyna/services/live_quotes.py +295 -0
- marketmyna/services/macro.py +182 -0
- marketmyna/services/market_calendar.py +399 -0
- marketmyna/services/market_mood.py +168 -0
- marketmyna/services/market_valuation.py +231 -0
- marketmyna/services/mf_holdings.py +199 -0
- marketmyna/services/mf_insights.py +132 -0
- marketmyna/services/mf_lumpsum.py +587 -0
- marketmyna/services/mf_portfolio_ai.py +195 -0
- marketmyna/services/nse_events.py +98 -0
- marketmyna/services/overview.py +366 -0
- marketmyna/services/ownership.py +143 -0
- marketmyna/services/quality_scores.py +199 -0
- marketmyna/services/rates.py +335 -0
- marketmyna/services/reconcile.py +343 -0
- marketmyna/services/report.py +584 -0
- marketmyna/services/report_i18n.py +678 -0
- marketmyna/services/rotation.py +224 -0
- marketmyna/services/runtime_scheduler.py +548 -0
- marketmyna/services/schedule_config.py +251 -0
- marketmyna/services/schedule_time.py +39 -0
- marketmyna/services/screens.py +398 -0
- marketmyna/services/sec_mdna.py +173 -0
- marketmyna/services/shariah.py +246 -0
- marketmyna/services/sip.py +148 -0
- marketmyna/services/sip_days.py +362 -0
- marketmyna/services/smart_file.py +595 -0
- marketmyna/services/smart_pdf.py +425 -0
- marketmyna/services/smart_report.py +370 -0
- marketmyna/services/social_sentiment.py +511 -0
- marketmyna/services/thesis.py +328 -0
- marketmyna/services/translate.py +146 -0
- marketmyna/services/valuation.py +173 -0
- marketmyna/services/worker_pool.py +149 -0
- marketmyna/services/world_markets.py +208 -0
- marketmyna/storage.py +722 -0
- marketmyna/strategies/README.md +51 -0
- marketmyna/strategies/bottom_volume.yaml +49 -0
- marketmyna/strategies/box_oscillation.yaml +69 -0
- marketmyna/strategies/bull_trend.yaml +49 -0
- marketmyna/strategies/chan_theory.yaml +58 -0
- marketmyna/strategies/dragon_head.yaml +44 -0
- marketmyna/strategies/emotion_cycle.yaml +79 -0
- marketmyna/strategies/event_driven.yaml +54 -0
- marketmyna/strategies/expectation_repricing.yaml +54 -0
- marketmyna/strategies/growth_quality.yaml +54 -0
- marketmyna/strategies/hot_theme.yaml +54 -0
- marketmyna/strategies/ma_golden_cross.yaml +46 -0
- marketmyna/strategies/one_yang_three_yin.yaml +37 -0
- marketmyna/strategies/shrink_pullback.yaml +46 -0
- marketmyna/strategies/volume_breakout.yaml +47 -0
- marketmyna/strategies/wave_theory.yaml +64 -0
- marketmyna/trading/__init__.py +2 -0
- marketmyna/trading/alerts.py +356 -0
- marketmyna/trading/backtest.py +317 -0
- marketmyna/trading/charges.py +82 -0
- marketmyna/trading/chart.py +197 -0
- marketmyna/trading/indicators.py +303 -0
- marketmyna/trading/journal.py +74 -0
- marketmyna/trading/knn.py +164 -0
- marketmyna/trading/lots.py +109 -0
- marketmyna/trading/market_hours.py +66 -0
- marketmyna/trading/options.py +554 -0
- marketmyna/trading/pairs.py +94 -0
- marketmyna/trading/risk.py +170 -0
- marketmyna/trading/scanner.py +500 -0
- marketmyna/trading/volatility.py +163 -0
- marketmyna/utils/__init__.py +1 -0
- marketmyna/utils/deadline.py +36 -0
- marketmyna/utils/log_redact.py +52 -0
- marketmyna/utils/yahoo_gate.py +183 -0
- marketmyna/utils/yf_lock.py +12 -0
- marketmyna/web/apple-touch-icon.png +0 -0
- marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
- marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
- marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
- marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
- marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
- marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
- marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
- marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
- marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
- marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
- marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
- marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
- marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
- marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
- marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
- marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
- marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
- marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
- marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
- marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
- marketmyna/web/assets/app-BP1o7WZS.js +1 -0
- marketmyna/web/assets/app-BkIUKdld.js +1 -0
- marketmyna/web/assets/app-CKeSYhEt.js +1 -0
- marketmyna/web/assets/app-CN1OERYX.js +1 -0
- marketmyna/web/assets/app-DIQllha5.js +1 -0
- marketmyna/web/assets/app-DeiAW0tX.js +1 -0
- marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
- marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
- marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
- marketmyna/web/assets/common-0wvpAVpd.js +1 -0
- marketmyna/web/assets/common-3ghy53Us.js +1 -0
- marketmyna/web/assets/common-Bib2unjX.js +1 -0
- marketmyna/web/assets/common-BvSJP0w_.js +1 -0
- marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
- marketmyna/web/assets/common-C_t-iZPf.js +1 -0
- marketmyna/web/assets/compare-DMcsx70H.js +1 -0
- marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
- marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
- marketmyna/web/assets/files-CpgiZRbN.js +1 -0
- marketmyna/web/assets/format-d8InM0Zw.js +9 -0
- marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
- marketmyna/web/assets/index-CKAnq-Or.js +12 -0
- marketmyna/web/assets/index-D4U8E6LH.css +2 -0
- marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
- marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
- marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
- marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
- marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
- marketmyna/web/assets/listings-BefgXpb1.js +1 -0
- marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
- marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
- marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
- marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
- marketmyna/web/assets/markets-DPO40TTe.js +1 -0
- marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
- marketmyna/web/assets/markets-ff1e792u.js +1 -0
- marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
- marketmyna/web/assets/options-BYAZnwzW.js +1 -0
- marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
- marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
- marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
- marketmyna/web/assets/toast-DDradTZw.js +1 -0
- marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
- marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
- marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
- marketmyna/web/brand/logo-mark.webp +0 -0
- marketmyna/web/favicon.png +0 -0
- marketmyna/web/icon-192.png +0 -0
- marketmyna/web/icon-512.png +0 -0
- marketmyna/web/index.html +34 -0
- marketmyna/web/manifest.webmanifest +28 -0
- marketmyna/web/robots.txt +2 -0
- marketmyna/web/sw.js +40 -0
- marketmyna/web/theme-init.js +37 -0
- marketmyna-0.2.0.dist-info/METADATA +345 -0
- marketmyna-0.2.0.dist-info/RECORD +344 -0
- marketmyna-0.2.0.dist-info/WHEEL +5 -0
- marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
- marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
- marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
- marketmyna-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Portfolio management functionality."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from marketmyna.core.home import app_home
|
|
8
|
+
|
|
9
|
+
from ..core.constants import Markets
|
|
10
|
+
from ..data.crypto import CryptoDataFetcher
|
|
11
|
+
from ..data.india import IndiaDataFetcher
|
|
12
|
+
from ..data.stocks import StockDataFetcher
|
|
13
|
+
from ..storage import DatabaseManager, HoldingRecord, PortfolioRecord, TransactionRecord, get_db_url
|
|
14
|
+
from .models import Holding, Portfolio, Transaction, TransactionType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PortfolioManager:
|
|
18
|
+
"""Manages portfolio operations."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, data_dir: Optional[Path] = None, config=None):
|
|
21
|
+
# data_dir is kept for backwards compatibility with existing call sites
|
|
22
|
+
# and as a per-instance db override (e.g. test isolation); persistence
|
|
23
|
+
# itself now lives in SQLite via storage.DatabaseManager, not JSON files.
|
|
24
|
+
self.data_dir = data_dir or app_home() / "portfolios"
|
|
25
|
+
self.config = config
|
|
26
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
db_url = get_db_url(config=config, override_dir=self.data_dir, filename="portfolios.db")
|
|
29
|
+
self._db = DatabaseManager.instance(db_url)
|
|
30
|
+
|
|
31
|
+
self.portfolios: dict[str, Portfolio] = {}
|
|
32
|
+
self._fetchers = {
|
|
33
|
+
Markets.US: StockDataFetcher(),
|
|
34
|
+
Markets.GLOBAL: StockDataFetcher(),
|
|
35
|
+
Markets.INDIA: IndiaDataFetcher(),
|
|
36
|
+
Markets.CRYPTO: CryptoDataFetcher(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
self._load_portfolios()
|
|
40
|
+
|
|
41
|
+
def _record_to_portfolio(self, record: PortfolioRecord) -> Portfolio:
|
|
42
|
+
"""Convert a PortfolioRecord (+ its holdings/transactions) to a Portfolio."""
|
|
43
|
+
holdings = {
|
|
44
|
+
h.symbol: Holding(
|
|
45
|
+
symbol=h.symbol,
|
|
46
|
+
quantity=h.quantity,
|
|
47
|
+
avg_cost=h.avg_cost,
|
|
48
|
+
current_price=h.current_price,
|
|
49
|
+
market=h.market or "US",
|
|
50
|
+
)
|
|
51
|
+
for h in record.holdings
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
transactions = [
|
|
55
|
+
Transaction(
|
|
56
|
+
symbol=t.symbol,
|
|
57
|
+
transaction_type=TransactionType(t.transaction_type),
|
|
58
|
+
quantity=t.quantity,
|
|
59
|
+
price=t.price,
|
|
60
|
+
date=t.date,
|
|
61
|
+
fees=t.fees or 0.0,
|
|
62
|
+
notes=t.notes or "",
|
|
63
|
+
id=t.id,
|
|
64
|
+
market=t.market or "",
|
|
65
|
+
)
|
|
66
|
+
for t in record.transactions
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
return Portfolio(
|
|
70
|
+
name=record.name,
|
|
71
|
+
holdings=holdings,
|
|
72
|
+
transactions=transactions,
|
|
73
|
+
cash_balance=record.cash_balance or 0.0,
|
|
74
|
+
currency=record.currency or "USD",
|
|
75
|
+
id=record.id,
|
|
76
|
+
created_at=record.created_at or datetime.now(),
|
|
77
|
+
kind=record.kind,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _load_portfolios(self) -> None:
|
|
81
|
+
"""Load portfolios from the database."""
|
|
82
|
+
try:
|
|
83
|
+
with self._db.get_session() as session:
|
|
84
|
+
for record in session.query(PortfolioRecord).all():
|
|
85
|
+
portfolio = self._record_to_portfolio(record)
|
|
86
|
+
self.portfolios[portfolio.name] = portfolio
|
|
87
|
+
except Exception as e:
|
|
88
|
+
print(f"Error loading portfolios: {e}")
|
|
89
|
+
|
|
90
|
+
def save_portfolio(self, portfolio: Portfolio) -> None:
|
|
91
|
+
"""Persist a portfolio: full replace of its holdings/transactions,
|
|
92
|
+
mirroring the previous "overwrite the whole JSON file" semantics."""
|
|
93
|
+
with self._db.get_session() as session:
|
|
94
|
+
record = session.query(PortfolioRecord).filter_by(name=portfolio.name).one_or_none()
|
|
95
|
+
if record is None:
|
|
96
|
+
record = PortfolioRecord(name=portfolio.name, created_at=portfolio.created_at)
|
|
97
|
+
session.add(record)
|
|
98
|
+
session.flush()
|
|
99
|
+
|
|
100
|
+
record.cash_balance = portfolio.cash_balance
|
|
101
|
+
record.currency = portfolio.currency
|
|
102
|
+
if portfolio.kind:
|
|
103
|
+
record.kind = portfolio.kind
|
|
104
|
+
|
|
105
|
+
record.holdings.clear()
|
|
106
|
+
# Flush the deletes now: SQLAlchemy's unit-of-work would otherwise
|
|
107
|
+
# emit the re-inserts (below, same portfolio_id+symbol) before the
|
|
108
|
+
# deletes within one flush and trip the unique constraint.
|
|
109
|
+
session.flush()
|
|
110
|
+
for holding in portfolio.holdings.values():
|
|
111
|
+
record.holdings.append(
|
|
112
|
+
HoldingRecord(
|
|
113
|
+
symbol=holding.symbol,
|
|
114
|
+
quantity=holding.quantity,
|
|
115
|
+
avg_cost=holding.avg_cost,
|
|
116
|
+
current_price=holding.current_price,
|
|
117
|
+
market=holding.market,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
record.transactions.clear()
|
|
122
|
+
for tx in portfolio.transactions:
|
|
123
|
+
record.transactions.append(
|
|
124
|
+
TransactionRecord(
|
|
125
|
+
symbol=tx.symbol,
|
|
126
|
+
transaction_type=tx.transaction_type.value,
|
|
127
|
+
quantity=tx.quantity,
|
|
128
|
+
price=tx.price,
|
|
129
|
+
date=tx.date,
|
|
130
|
+
fees=tx.fees,
|
|
131
|
+
notes=tx.notes,
|
|
132
|
+
market=tx.market or None,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
session.flush()
|
|
137
|
+
portfolio.id = record.id
|
|
138
|
+
|
|
139
|
+
def create_portfolio(self, name: str, currency: str = "USD", kind: Optional[str] = None) -> Portfolio:
|
|
140
|
+
"""Create a new portfolio ("stocks" or "funds")."""
|
|
141
|
+
if name in self.portfolios:
|
|
142
|
+
raise ValueError(f"Portfolio '{name}' already exists")
|
|
143
|
+
|
|
144
|
+
portfolio = Portfolio(name=name, currency=currency, kind=kind)
|
|
145
|
+
self.portfolios[name] = portfolio
|
|
146
|
+
self.save_portfolio(portfolio)
|
|
147
|
+
return portfolio
|
|
148
|
+
|
|
149
|
+
def delete_portfolio(self, name: str) -> None:
|
|
150
|
+
"""Delete a portfolio."""
|
|
151
|
+
if name not in self.portfolios:
|
|
152
|
+
raise ValueError(f"Portfolio '{name}' not found")
|
|
153
|
+
|
|
154
|
+
with self._db.get_session() as session:
|
|
155
|
+
record = session.query(PortfolioRecord).filter_by(name=name).one_or_none()
|
|
156
|
+
if record is not None:
|
|
157
|
+
session.delete(record)
|
|
158
|
+
|
|
159
|
+
del self.portfolios[name]
|
|
160
|
+
|
|
161
|
+
def rename_portfolio(self, name: str, new_name: str) -> Portfolio:
|
|
162
|
+
"""Rename a portfolio, keeping its holdings and history."""
|
|
163
|
+
new_name = new_name.strip()
|
|
164
|
+
if name not in self.portfolios:
|
|
165
|
+
raise ValueError(f"Portfolio '{name}' not found")
|
|
166
|
+
if not new_name:
|
|
167
|
+
raise ValueError("Portfolio name cannot be empty")
|
|
168
|
+
if new_name == name:
|
|
169
|
+
return self.portfolios[name]
|
|
170
|
+
if new_name in self.portfolios:
|
|
171
|
+
raise ValueError(f"Portfolio '{new_name}' already exists")
|
|
172
|
+
|
|
173
|
+
with self._db.get_session() as session:
|
|
174
|
+
record = session.query(PortfolioRecord).filter_by(name=name).one()
|
|
175
|
+
record.name = new_name
|
|
176
|
+
|
|
177
|
+
portfolio = self.portfolios.pop(name)
|
|
178
|
+
portfolio.name = new_name
|
|
179
|
+
self.portfolios[new_name] = portfolio
|
|
180
|
+
return portfolio
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _replay(transactions: list[Transaction], previous: dict[str, Holding]) -> dict[str, Holding]:
|
|
184
|
+
"""Rebuild holdings from the transaction history, in order. Raises
|
|
185
|
+
ValueError if a sell would exceed what was held at that point."""
|
|
186
|
+
holdings: dict[str, Holding] = {}
|
|
187
|
+
for t in transactions:
|
|
188
|
+
if t.transaction_type == TransactionType.BUY:
|
|
189
|
+
h = holdings.get(t.symbol)
|
|
190
|
+
if h is None:
|
|
191
|
+
old = previous.get(t.symbol)
|
|
192
|
+
holdings[t.symbol] = Holding(
|
|
193
|
+
symbol=t.symbol,
|
|
194
|
+
quantity=t.quantity,
|
|
195
|
+
avg_cost=t.price,
|
|
196
|
+
current_price=old.current_price if old else 0.0,
|
|
197
|
+
market=t.market or (old.market if old else "US"),
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
total_cost = h.cost_basis + t.total_cost
|
|
201
|
+
h.quantity += t.quantity
|
|
202
|
+
h.avg_cost = total_cost / h.quantity
|
|
203
|
+
elif t.transaction_type == TransactionType.SELL:
|
|
204
|
+
h = holdings.get(t.symbol)
|
|
205
|
+
if h is None or t.quantity > h.quantity + 1e-9:
|
|
206
|
+
held = h.quantity if h else 0
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Can't remove it: the later sell of {t.quantity:g} {t.symbol} would have only "
|
|
209
|
+
f"{held:g} shares to sell - delete that sell first"
|
|
210
|
+
)
|
|
211
|
+
h.quantity -= t.quantity
|
|
212
|
+
if h.quantity <= 1e-9:
|
|
213
|
+
del holdings[t.symbol]
|
|
214
|
+
elif t.transaction_type == TransactionType.SPLIT and t.symbol in holdings:
|
|
215
|
+
_apply_split(holdings[t.symbol], t.quantity)
|
|
216
|
+
return holdings
|
|
217
|
+
|
|
218
|
+
def delete_transaction(self, portfolio_name: str, index: int) -> None:
|
|
219
|
+
"""Remove one transaction (e.g. a typo) and rebuild the holdings
|
|
220
|
+
from the remaining history."""
|
|
221
|
+
if portfolio_name not in self.portfolios:
|
|
222
|
+
raise ValueError(f"Portfolio '{portfolio_name}' not found")
|
|
223
|
+
portfolio = self.portfolios[portfolio_name]
|
|
224
|
+
if not 0 <= index < len(portfolio.transactions):
|
|
225
|
+
raise IndexError(f"Transaction {index} not found")
|
|
226
|
+
|
|
227
|
+
remaining = portfolio.transactions[:index] + portfolio.transactions[index + 1:]
|
|
228
|
+
portfolio.holdings = self._replay(remaining, portfolio.holdings)
|
|
229
|
+
portfolio.transactions = remaining
|
|
230
|
+
self.save_portfolio(portfolio)
|
|
231
|
+
|
|
232
|
+
def add_transaction(
|
|
233
|
+
self,
|
|
234
|
+
portfolio_name: str,
|
|
235
|
+
symbol: str,
|
|
236
|
+
transaction_type: TransactionType,
|
|
237
|
+
quantity: float,
|
|
238
|
+
price: float,
|
|
239
|
+
date: Optional[datetime] = None,
|
|
240
|
+
fees: float = 0.0,
|
|
241
|
+
notes: str = "",
|
|
242
|
+
market: str = "US",
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Add a transaction to a portfolio."""
|
|
245
|
+
if portfolio_name not in self.portfolios:
|
|
246
|
+
raise ValueError(f"Portfolio '{portfolio_name}' not found")
|
|
247
|
+
|
|
248
|
+
portfolio = self.portfolios[portfolio_name]
|
|
249
|
+
|
|
250
|
+
# Validate first: a rejected sell or split must not stay in the in-memory
|
|
251
|
+
# history, where the next save would write it to the database.
|
|
252
|
+
if transaction_type == TransactionType.SELL:
|
|
253
|
+
if symbol not in portfolio.holdings:
|
|
254
|
+
raise ValueError(f"No holding for {symbol}")
|
|
255
|
+
if quantity > portfolio.holdings[symbol].quantity:
|
|
256
|
+
raise ValueError(f"Cannot sell {quantity} shares, only {portfolio.holdings[symbol].quantity} available")
|
|
257
|
+
elif transaction_type == TransactionType.SPLIT:
|
|
258
|
+
if symbol not in portfolio.holdings:
|
|
259
|
+
raise ValueError(f"No holding for {symbol} to split")
|
|
260
|
+
if quantity <= 0:
|
|
261
|
+
raise ValueError("Split ratio must be above 0")
|
|
262
|
+
|
|
263
|
+
transaction = Transaction(
|
|
264
|
+
symbol=symbol,
|
|
265
|
+
transaction_type=transaction_type,
|
|
266
|
+
quantity=quantity,
|
|
267
|
+
price=price,
|
|
268
|
+
date=date or datetime.now(),
|
|
269
|
+
fees=fees,
|
|
270
|
+
notes=notes,
|
|
271
|
+
market=market,
|
|
272
|
+
)
|
|
273
|
+
portfolio.transactions.append(transaction)
|
|
274
|
+
|
|
275
|
+
# Update holdings
|
|
276
|
+
if transaction_type == TransactionType.BUY:
|
|
277
|
+
if symbol in portfolio.holdings:
|
|
278
|
+
holding = portfolio.holdings[symbol]
|
|
279
|
+
total_cost = holding.cost_basis + transaction.total_cost
|
|
280
|
+
total_qty = holding.quantity + quantity
|
|
281
|
+
holding.avg_cost = total_cost / total_qty
|
|
282
|
+
holding.quantity = total_qty
|
|
283
|
+
else:
|
|
284
|
+
portfolio.holdings[symbol] = Holding(
|
|
285
|
+
symbol=symbol,
|
|
286
|
+
quantity=quantity,
|
|
287
|
+
avg_cost=price,
|
|
288
|
+
market=market,
|
|
289
|
+
)
|
|
290
|
+
elif transaction_type == TransactionType.SELL:
|
|
291
|
+
holding = portfolio.holdings[symbol]
|
|
292
|
+
holding.quantity -= quantity
|
|
293
|
+
if holding.quantity == 0:
|
|
294
|
+
del portfolio.holdings[symbol]
|
|
295
|
+
elif transaction_type == TransactionType.SPLIT:
|
|
296
|
+
_apply_split(portfolio.holdings[symbol], quantity)
|
|
297
|
+
|
|
298
|
+
self.save_portfolio(portfolio)
|
|
299
|
+
|
|
300
|
+
def update_prices(self, portfolio_name: str) -> None:
|
|
301
|
+
"""Update current prices for all holdings in a portfolio."""
|
|
302
|
+
if portfolio_name not in self.portfolios:
|
|
303
|
+
raise ValueError(f"Portfolio '{portfolio_name}' not found")
|
|
304
|
+
|
|
305
|
+
portfolio = self.portfolios[portfolio_name]
|
|
306
|
+
|
|
307
|
+
for symbol, holding in portfolio.holdings.items():
|
|
308
|
+
if (holding.market or "").lower() == "mf":
|
|
309
|
+
from ..services.mf_holdings import latest
|
|
310
|
+
|
|
311
|
+
info = latest(symbol)
|
|
312
|
+
if info:
|
|
313
|
+
holding.current_price = info["nav"]
|
|
314
|
+
continue
|
|
315
|
+
if (holding.market or "").lower() == "india":
|
|
316
|
+
close = nse_official_close(symbol)
|
|
317
|
+
if close:
|
|
318
|
+
holding.current_price = close
|
|
319
|
+
continue
|
|
320
|
+
try:
|
|
321
|
+
market = Markets(holding.market.lower()) if holding.market else Markets.US
|
|
322
|
+
fetcher = self._fetchers.get(market, self._fetchers[Markets.US])
|
|
323
|
+
quote = fetcher.get_quote(symbol)
|
|
324
|
+
holding.current_price = quote["price"]
|
|
325
|
+
except Exception as e:
|
|
326
|
+
print(f"Error updating price for {symbol}: {e}")
|
|
327
|
+
|
|
328
|
+
def get_portfolio_summary(self, portfolio_name: str) -> dict:
|
|
329
|
+
"""Get a summary of the portfolio."""
|
|
330
|
+
if portfolio_name not in self.portfolios:
|
|
331
|
+
raise ValueError(f"Portfolio '{portfolio_name}' not found")
|
|
332
|
+
|
|
333
|
+
portfolio = self.portfolios[portfolio_name]
|
|
334
|
+
self.update_prices(portfolio_name)
|
|
335
|
+
|
|
336
|
+
return portfolio.to_dict()
|
|
337
|
+
|
|
338
|
+
def get_all_portfolios(self) -> list[str]:
|
|
339
|
+
"""Get list of all portfolio names."""
|
|
340
|
+
return list(self.portfolios.keys())
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def nse_official_close(symbol: str, now: Optional[datetime] = None, rows: Optional[dict] = None) -> Optional[float]:
|
|
344
|
+
"""NSE's official closing price while the market is shut, else None.
|
|
345
|
+
|
|
346
|
+
After the close, brokers show the official close (the volume-weighted
|
|
347
|
+
price of the last 30 minutes) rather than the last single trade that a
|
|
348
|
+
live quote returns, so a valuation at the last trade drifts a few paise
|
|
349
|
+
a share from the broker's. Uses the bhavcopy close from the listing,
|
|
350
|
+
but only once it is the latest session's: between 15:30 and the evening
|
|
351
|
+
publication the listing still holds yesterday's close, so return None
|
|
352
|
+
and let the live quote stand. Exchange holidays are not modelled, so on
|
|
353
|
+
a weekday holiday evening this also returns None.
|
|
354
|
+
"""
|
|
355
|
+
from zoneinfo import ZoneInfo
|
|
356
|
+
|
|
357
|
+
from ..trading.market_hours import is_open
|
|
358
|
+
|
|
359
|
+
tz = ZoneInfo("Asia/Kolkata")
|
|
360
|
+
local = now.astimezone(tz) if now else datetime.now(tz)
|
|
361
|
+
if is_open("india", local):
|
|
362
|
+
return None
|
|
363
|
+
if rows is None:
|
|
364
|
+
from ..data.listings import get_store
|
|
365
|
+
|
|
366
|
+
found = get_store().lookup([("india", "stock", symbol), ("india", "etf", symbol)])
|
|
367
|
+
rows = {k[2]: v for k, v in found.items()}
|
|
368
|
+
row = rows.get(symbol)
|
|
369
|
+
if not row or not row.get("price") or not row.get("close_date"):
|
|
370
|
+
return None
|
|
371
|
+
after_close_today = local.weekday() < 5 and local.time() > datetime.strptime("15:30", "%H:%M").time()
|
|
372
|
+
if after_close_today and row["close_date"] != local.date().isoformat():
|
|
373
|
+
return None # today's official close is not published yet
|
|
374
|
+
return float(row["price"])
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _apply_split(holding: Holding, ratio: float) -> None:
|
|
378
|
+
"""Split or bonus: `ratio` = shares held now for each share before (5 for a
|
|
379
|
+
1:5 split, 2 for a 1:1 bonus). Same money invested, so the average cost falls."""
|
|
380
|
+
if ratio <= 0:
|
|
381
|
+
raise ValueError("Split ratio must be above 0")
|
|
382
|
+
holding.quantity *= ratio
|
|
383
|
+
holding.avg_cost /= ratio
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Portfolio data models."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TransactionType(Enum):
|
|
10
|
+
"""Transaction types."""
|
|
11
|
+
BUY = "buy"
|
|
12
|
+
SELL = "sell"
|
|
13
|
+
DIVIDEND = "dividend"
|
|
14
|
+
SPLIT = "split"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Transaction:
|
|
19
|
+
"""Represents a single transaction."""
|
|
20
|
+
|
|
21
|
+
symbol: str
|
|
22
|
+
transaction_type: TransactionType
|
|
23
|
+
quantity: float
|
|
24
|
+
price: float
|
|
25
|
+
date: datetime
|
|
26
|
+
fees: float = 0.0
|
|
27
|
+
notes: str = ""
|
|
28
|
+
id: Optional[int] = None
|
|
29
|
+
market: str = ""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def total_cost(self) -> float:
|
|
33
|
+
"""Total cost including fees."""
|
|
34
|
+
if self.transaction_type == TransactionType.BUY:
|
|
35
|
+
return (self.quantity * self.price) + self.fees
|
|
36
|
+
elif self.transaction_type == TransactionType.SELL:
|
|
37
|
+
return (self.quantity * self.price) - self.fees
|
|
38
|
+
return 0.0
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"symbol": self.symbol,
|
|
43
|
+
"transaction_type": self.transaction_type.value,
|
|
44
|
+
"quantity": self.quantity,
|
|
45
|
+
"price": self.price,
|
|
46
|
+
"date": self.date.isoformat() if self.date else None,
|
|
47
|
+
"fees": self.fees,
|
|
48
|
+
"notes": self.notes,
|
|
49
|
+
"market": self.market,
|
|
50
|
+
"total": self.total_cost,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Holding:
|
|
56
|
+
"""Represents a current holding in the portfolio."""
|
|
57
|
+
|
|
58
|
+
symbol: str
|
|
59
|
+
quantity: float
|
|
60
|
+
avg_cost: float
|
|
61
|
+
current_price: float = 0.0
|
|
62
|
+
market: str = "US"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def market_value(self) -> float:
|
|
66
|
+
"""Current market value of the holding."""
|
|
67
|
+
return self.quantity * self.current_price
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def cost_basis(self) -> float:
|
|
71
|
+
"""Total cost basis of the holding."""
|
|
72
|
+
return self.quantity * self.avg_cost
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def unrealized_gain(self) -> float:
|
|
76
|
+
"""Unrealized gain/loss."""
|
|
77
|
+
return self.market_value - self.cost_basis
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def unrealized_gain_percent(self) -> float:
|
|
81
|
+
"""Unrealized gain/loss as percentage."""
|
|
82
|
+
if self.cost_basis == 0:
|
|
83
|
+
return 0.0
|
|
84
|
+
return (self.unrealized_gain / self.cost_basis) * 100
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict:
|
|
87
|
+
"""Convert to dictionary."""
|
|
88
|
+
return {
|
|
89
|
+
"symbol": self.symbol,
|
|
90
|
+
"quantity": self.quantity,
|
|
91
|
+
"avg_cost": self.avg_cost,
|
|
92
|
+
"current_price": self.current_price,
|
|
93
|
+
"market_value": self.market_value,
|
|
94
|
+
"cost_basis": self.cost_basis,
|
|
95
|
+
"unrealized_gain": self.unrealized_gain,
|
|
96
|
+
"unrealized_gain_percent": self.unrealized_gain_percent,
|
|
97
|
+
"market": self.market,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Portfolio:
|
|
103
|
+
"""Represents a user's portfolio."""
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
holdings: dict[str, Holding] = field(default_factory=dict)
|
|
107
|
+
transactions: list[Transaction] = field(default_factory=list)
|
|
108
|
+
cash_balance: float = 0.0
|
|
109
|
+
currency: str = "USD"
|
|
110
|
+
id: Optional[int] = None
|
|
111
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
112
|
+
kind: Optional[str] = None # "stocks" | "funds"; None = inferred from the holdings
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def total_market_value(self) -> float:
|
|
116
|
+
"""Total market value of all holdings."""
|
|
117
|
+
return sum(h.market_value for h in self.holdings.values())
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def total_cost_basis(self) -> float:
|
|
121
|
+
"""Total cost basis of all holdings."""
|
|
122
|
+
return sum(h.cost_basis for h in self.holdings.values())
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def total_unrealized_gain(self) -> float:
|
|
126
|
+
"""Total unrealized gain/loss."""
|
|
127
|
+
return self.total_market_value - self.total_cost_basis
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def total_unrealized_gain_percent(self) -> float:
|
|
131
|
+
"""Total unrealized gain/loss as percentage."""
|
|
132
|
+
if self.total_cost_basis == 0:
|
|
133
|
+
return 0.0
|
|
134
|
+
return (self.total_unrealized_gain / self.total_cost_basis) * 100
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def total_value(self) -> float:
|
|
138
|
+
"""Total portfolio value including cash."""
|
|
139
|
+
return self.total_market_value + self.cash_balance
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict:
|
|
142
|
+
"""Convert to dictionary."""
|
|
143
|
+
return {
|
|
144
|
+
"name": self.name,
|
|
145
|
+
"holdings": {s: h.to_dict() for s, h in self.holdings.items()},
|
|
146
|
+
"cash_balance": self.cash_balance,
|
|
147
|
+
"currency": self.currency,
|
|
148
|
+
"total_market_value": self.total_market_value,
|
|
149
|
+
"total_cost_basis": self.total_cost_basis,
|
|
150
|
+
"total_unrealized_gain": self.total_unrealized_gain,
|
|
151
|
+
"total_unrealized_gain_percent": self.total_unrealized_gain_percent,
|
|
152
|
+
"total_value": self.total_value,
|
|
153
|
+
# index = position in the list, which is what delete_transaction takes.
|
|
154
|
+
"transactions": [{"index": i, **t.to_dict()} for i, t in enumerate(self.transactions)],
|
|
155
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Two portfolio construction methods from the literature, on daily returns.
|
|
2
|
+
|
|
3
|
+
- Hierarchical risk parity (López de Prado, "Building Diversified Portfolios
|
|
4
|
+
that Outperform Out of Sample", 2016): cluster holdings by correlation,
|
|
5
|
+
order them so similar ones sit together, then split weight top-down
|
|
6
|
+
between clusters in inverse proportion to each cluster's variance. Needs
|
|
7
|
+
no covariance inversion, so it is stable with few observations.
|
|
8
|
+
- Minimum CVaR (Rockafellar & Uryasev, "Optimization of Conditional
|
|
9
|
+
Value-at-Risk", 2000): the long-only mix whose average loss on its worst
|
|
10
|
+
5% of days, in the history given, is smallest; solved as a linear programme.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from scipy.cluster.hierarchy import leaves_list, linkage
|
|
18
|
+
from scipy.optimize import linprog
|
|
19
|
+
from scipy.spatial.distance import squareform
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ivp(cov: np.ndarray) -> np.ndarray:
|
|
23
|
+
w = 1 / np.diag(cov)
|
|
24
|
+
return w / w.sum()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cluster_var(cov: pd.DataFrame, items: list) -> float:
|
|
28
|
+
sub = cov.loc[items, items].to_numpy()
|
|
29
|
+
w = _ivp(sub)
|
|
30
|
+
return float(w @ sub @ w)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def hrp(returns: pd.DataFrame) -> pd.Series:
|
|
34
|
+
"""Weights summing to 1, indexed like returns.columns."""
|
|
35
|
+
cols = list(returns.columns)
|
|
36
|
+
if len(cols) == 1:
|
|
37
|
+
return pd.Series([1.0], index=cols)
|
|
38
|
+
cov, corr = returns.cov(), returns.corr().clip(-1, 1)
|
|
39
|
+
# a writable copy: with pandas copy-on-write, DataFrame.values is read-only
|
|
40
|
+
dist = np.sqrt(((1 - corr) / 2).clip(lower=0)).to_numpy(copy=True)
|
|
41
|
+
np.fill_diagonal(dist, 0)
|
|
42
|
+
order = [cols[i] for i in leaves_list(linkage(squareform(dist, checks=False), method="single"))]
|
|
43
|
+
w = pd.Series(1.0, index=order)
|
|
44
|
+
clusters = [order]
|
|
45
|
+
while clusters:
|
|
46
|
+
nxt = []
|
|
47
|
+
for c in clusters:
|
|
48
|
+
if len(c) < 2:
|
|
49
|
+
continue
|
|
50
|
+
half = len(c) // 2
|
|
51
|
+
left, right = c[:half], c[half:]
|
|
52
|
+
v_l, v_r = _cluster_var(cov, left), _cluster_var(cov, right)
|
|
53
|
+
alpha = 1 - v_l / (v_l + v_r)
|
|
54
|
+
w[left] *= alpha
|
|
55
|
+
w[right] *= 1 - alpha
|
|
56
|
+
nxt += [left, right]
|
|
57
|
+
clusters = nxt
|
|
58
|
+
return w.reindex(cols) / w.sum()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def min_cvar(returns: pd.DataFrame, beta: float = 0.95, max_weight: float = 1.0) -> pd.Series:
|
|
62
|
+
"""Long-only weights minimising the historical CVaR at `beta` (Rockafellar-Uryasev LP).
|
|
63
|
+
|
|
64
|
+
Variables: n weights, VaR level a, T excess losses u_t >= 0.
|
|
65
|
+
minimise a + sum(u) / ((1 - beta) T)
|
|
66
|
+
subject to u_t >= -r_t . w - a, sum(w) = 1, 0 <= w <= max_weight.
|
|
67
|
+
"""
|
|
68
|
+
r = returns.to_numpy()
|
|
69
|
+
t, n = r.shape
|
|
70
|
+
cap = max(max_weight, 1.0 / n)
|
|
71
|
+
c = np.concatenate([np.zeros(n), [1.0], np.full(t, 1.0 / ((1 - beta) * t))])
|
|
72
|
+
# -r_t.w - a - u_t <= 0
|
|
73
|
+
a_ub = np.hstack([-r, -np.ones((t, 1)), -np.eye(t)])
|
|
74
|
+
b_ub = np.zeros(t)
|
|
75
|
+
a_eq = np.concatenate([np.ones(n), [0.0], np.zeros(t)])[None, :]
|
|
76
|
+
bounds = [(0, cap)] * n + [(None, None)] + [(0, None)] * t
|
|
77
|
+
res = linprog(c, A_ub=a_ub, b_ub=b_ub, A_eq=a_eq, b_eq=[1.0], bounds=bounds, method="highs")
|
|
78
|
+
if not res.success:
|
|
79
|
+
raise ValueError(f"Minimum-CVaR solve failed: {res.message}")
|
|
80
|
+
w = np.clip(res.x[:n], 0, None)
|
|
81
|
+
return pd.Series(w / w.sum(), index=returns.columns)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cvar(port_returns: pd.Series | np.ndarray, beta: float = 0.95) -> float:
|
|
85
|
+
"""Average daily loss on the worst (1 - beta) of days, as a positive fraction."""
|
|
86
|
+
x = np.sort(np.asarray(port_returns, dtype=float))
|
|
87
|
+
k = max(1, int(np.ceil(round((1 - beta) * len(x), 9)))) # round: 0.05 * 40 is 2.0000000000000018
|
|
88
|
+
return float(-x[:k].mean())
|