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.
Files changed (344) hide show
  1. marketmyna/__init__.py +14 -0
  2. marketmyna/agent/__init__.py +8 -0
  3. marketmyna/agent/agents.py +543 -0
  4. marketmyna/agent/context.py +69 -0
  5. marketmyna/agent/dashboard.py +55 -0
  6. marketmyna/agent/orchestrator.py +153 -0
  7. marketmyna/agent/sentiment.py +150 -0
  8. marketmyna/agent/skills.py +119 -0
  9. marketmyna/ai/__init__.py +1 -0
  10. marketmyna/ai/portfolio_optimizer.py +330 -0
  11. marketmyna/analysis/__init__.py +6 -0
  12. marketmyna/analysis/fundamental.py +329 -0
  13. marketmyna/analysis/sip_optimizer.py +81 -0
  14. marketmyna/analysis/technical.py +321 -0
  15. marketmyna/api/__init__.py +1 -0
  16. marketmyna/api/app.py +209 -0
  17. marketmyna/api/deps.py +63 -0
  18. marketmyna/api/middlewares/__init__.py +0 -0
  19. marketmyna/api/middlewares/auth.py +116 -0
  20. marketmyna/api/middlewares/csrf.py +66 -0
  21. marketmyna/api/middlewares/error_handler.py +38 -0
  22. marketmyna/api/middlewares/slashes.py +27 -0
  23. marketmyna/api/v1/__init__.py +0 -0
  24. marketmyna/api/v1/endpoints/__init__.py +0 -0
  25. marketmyna/api/v1/endpoints/account.py +166 -0
  26. marketmyna/api/v1/endpoints/agent.py +114 -0
  27. marketmyna/api/v1/endpoints/bot.py +38 -0
  28. marketmyna/api/v1/endpoints/calendar.py +269 -0
  29. marketmyna/api/v1/endpoints/data_sources.py +174 -0
  30. marketmyna/api/v1/endpoints/health.py +10 -0
  31. marketmyna/api/v1/endpoints/listings.py +564 -0
  32. marketmyna/api/v1/endpoints/macro.py +163 -0
  33. marketmyna/api/v1/endpoints/portfolio.py +530 -0
  34. marketmyna/api/v1/endpoints/push.py +119 -0
  35. marketmyna/api/v1/endpoints/reports.py +49 -0
  36. marketmyna/api/v1/endpoints/scheduler.py +219 -0
  37. marketmyna/api/v1/endpoints/stocks.py +400 -0
  38. marketmyna/api/v1/endpoints/system_config.py +157 -0
  39. marketmyna/api/v1/endpoints/thesis.py +112 -0
  40. marketmyna/api/v1/endpoints/trade.py +709 -0
  41. marketmyna/api/v1/endpoints/updates.py +394 -0
  42. marketmyna/api/v1/endpoints/watchlists.py +179 -0
  43. marketmyna/api/v1/router.py +43 -0
  44. marketmyna/bot/__init__.py +14 -0
  45. marketmyna/bot/commands/__init__.py +0 -0
  46. marketmyna/bot/commands/analyze.py +40 -0
  47. marketmyna/bot/commands/ask.py +51 -0
  48. marketmyna/bot/commands/base.py +35 -0
  49. marketmyna/bot/commands/batch.py +53 -0
  50. marketmyna/bot/commands/common.py +84 -0
  51. marketmyna/bot/commands/help.py +43 -0
  52. marketmyna/bot/commands/markets.py +153 -0
  53. marketmyna/bot/commands/portfolio.py +145 -0
  54. marketmyna/bot/commands/research.py +323 -0
  55. marketmyna/bot/commands/status.py +48 -0
  56. marketmyna/bot/commands/trade.py +176 -0
  57. marketmyna/bot/dispatcher.py +95 -0
  58. marketmyna/bot/formatting.py +25 -0
  59. marketmyna/bot/models.py +27 -0
  60. marketmyna/bot/registry.py +50 -0
  61. marketmyna/bot/telegram_listener.py +227 -0
  62. marketmyna/cli/__init__.py +1 -0
  63. marketmyna/cli/scheduled_run.py +292 -0
  64. marketmyna/core/__init__.py +6 -0
  65. marketmyna/core/compliance.py +53 -0
  66. marketmyna/core/config.py +377 -0
  67. marketmyna/core/constants.py +69 -0
  68. marketmyna/core/home.py +22 -0
  69. marketmyna/core/numbers.py +28 -0
  70. marketmyna/core/secret_store.py +104 -0
  71. marketmyna/core/tenant.py +130 -0
  72. marketmyna/data/__init__.py +8 -0
  73. marketmyna/data/base.py +36 -0
  74. marketmyna/data/categories.py +272 -0
  75. marketmyna/data/china.py +360 -0
  76. marketmyna/data/crypto.py +200 -0
  77. marketmyna/data/ecb_fx.py +131 -0
  78. marketmyna/data/extra_sources.py +569 -0
  79. marketmyna/data/india.py +241 -0
  80. marketmyna/data/india_filings.py +343 -0
  81. marketmyna/data/listings.py +1314 -0
  82. marketmyna/data/mutual_funds.py +34 -0
  83. marketmyna/data/news.py +98 -0
  84. marketmyna/data/news_search.py +346 -0
  85. marketmyna/data/nse_deals.py +138 -0
  86. marketmyna/data/resilience.py +173 -0
  87. marketmyna/data/sec_facts.py +252 -0
  88. marketmyna/data/shariah.py +283 -0
  89. marketmyna/data/social/__init__.py +9 -0
  90. marketmyna/data/social/base.py +100 -0
  91. marketmyna/data/social/browser.py +92 -0
  92. marketmyna/data/social/hackernews.py +53 -0
  93. marketmyna/data/social/reddit.py +397 -0
  94. marketmyna/data/social/stocktwits.py +63 -0
  95. marketmyna/data/social/valuepickr.py +53 -0
  96. marketmyna/data/stocks.py +197 -0
  97. marketmyna/data/yahoo_price.py +48 -0
  98. marketmyna/launcher.py +71 -0
  99. marketmyna/llm/__init__.py +33 -0
  100. marketmyna/llm/backend_factory.py +51 -0
  101. marketmyna/llm/backend_registry.py +19 -0
  102. marketmyna/llm/channels.py +447 -0
  103. marketmyna/llm/json_reply.py +63 -0
  104. marketmyna/llm/litellm_backend.py +197 -0
  105. marketmyna/llm/model_catalog.py +166 -0
  106. marketmyna/llm/model_health.py +193 -0
  107. marketmyna/llm/protocol.py +111 -0
  108. marketmyna/llm/providers.py +128 -0
  109. marketmyna/llm/store.py +128 -0
  110. marketmyna/notifiers/__init__.py +35 -0
  111. marketmyna/notifiers/apprise_sender.py +43 -0
  112. marketmyna/notifiers/astrbot.py +47 -0
  113. marketmyna/notifiers/bark.py +24 -0
  114. marketmyna/notifiers/base.py +80 -0
  115. marketmyna/notifiers/dingtalk.py +54 -0
  116. marketmyna/notifiers/discord.py +39 -0
  117. marketmyna/notifiers/dispatcher.py +153 -0
  118. marketmyna/notifiers/email.py +99 -0
  119. marketmyna/notifiers/feishu.py +57 -0
  120. marketmyna/notifiers/googlechat.py +24 -0
  121. marketmyna/notifiers/gotify.py +36 -0
  122. marketmyna/notifiers/matrix.py +28 -0
  123. marketmyna/notifiers/ntfy.py +36 -0
  124. marketmyna/notifiers/pushbullet.py +24 -0
  125. marketmyna/notifiers/pushover.py +41 -0
  126. marketmyna/notifiers/pushplus.py +41 -0
  127. marketmyna/notifiers/serverchan3.py +40 -0
  128. marketmyna/notifiers/settings_schema.py +119 -0
  129. marketmyna/notifiers/signal_messenger.py +25 -0
  130. marketmyna/notifiers/slack.py +36 -0
  131. marketmyna/notifiers/teams.py +37 -0
  132. marketmyna/notifiers/telegram.py +81 -0
  133. marketmyna/notifiers/twilio.py +33 -0
  134. marketmyna/notifiers/webhook.py +44 -0
  135. marketmyna/notifiers/webpush.py +222 -0
  136. marketmyna/notifiers/wechat.py +40 -0
  137. marketmyna/notifiers/whatsapp.py +27 -0
  138. marketmyna/portfolio/__init__.py +6 -0
  139. marketmyna/portfolio/analytics.py +336 -0
  140. marketmyna/portfolio/manager.py +383 -0
  141. marketmyna/portfolio/models.py +155 -0
  142. marketmyna/portfolio/optimizers.py +88 -0
  143. marketmyna/portfolio/review.py +411 -0
  144. marketmyna/services/__init__.py +1 -0
  145. marketmyna/services/accounts.py +278 -0
  146. marketmyna/services/ai_access.py +94 -0
  147. marketmyna/services/analysis_run.py +491 -0
  148. marketmyna/services/big_trades.py +335 -0
  149. marketmyna/services/brokers/__init__.py +0 -0
  150. marketmyna/services/brokers/angel.py +271 -0
  151. marketmyna/services/brokers/finnhub.py +209 -0
  152. marketmyna/services/byok_detect.py +196 -0
  153. marketmyna/services/calendar.py +748 -0
  154. marketmyna/services/central_banks.py +202 -0
  155. marketmyna/services/commodities.py +134 -0
  156. marketmyna/services/compare.py +307 -0
  157. marketmyna/services/cot.py +148 -0
  158. marketmyna/services/country_macro.py +135 -0
  159. marketmyna/services/crude_sensitivity.py +239 -0
  160. marketmyna/services/crypto_mood.py +135 -0
  161. marketmyna/services/crypto_screens.py +226 -0
  162. marketmyna/services/data_export.py +91 -0
  163. marketmyna/services/dcf.py +220 -0
  164. marketmyna/services/dcf_model.py +194 -0
  165. marketmyna/services/delivery.py +142 -0
  166. marketmyna/services/discovery.py +146 -0
  167. marketmyna/services/dividends.py +95 -0
  168. marketmyna/services/earnings.py +305 -0
  169. marketmyna/services/econ_calendar.py +495 -0
  170. marketmyna/services/eia_stocks.py +115 -0
  171. marketmyna/services/estimates.py +109 -0
  172. marketmyna/services/etf.py +236 -0
  173. marketmyna/services/etf_rotation.py +193 -0
  174. marketmyna/services/etf_screens.py +196 -0
  175. marketmyna/services/financials.py +639 -0
  176. marketmyna/services/fund_ai.py +181 -0
  177. marketmyna/services/fund_analytics.py +371 -0
  178. marketmyna/services/fund_cost.py +147 -0
  179. marketmyna/services/fund_holdings.py +664 -0
  180. marketmyna/services/fund_peers.py +247 -0
  181. marketmyna/services/fund_rotation.py +206 -0
  182. marketmyna/services/fund_style.py +179 -0
  183. marketmyna/services/funds.py +70 -0
  184. marketmyna/services/hot_stocks.py +358 -0
  185. marketmyna/services/india_flows.py +263 -0
  186. marketmyna/services/india_rates.py +258 -0
  187. marketmyna/services/investor_lenses.py +271 -0
  188. marketmyna/services/leadership.py +165 -0
  189. marketmyna/services/live_market.py +497 -0
  190. marketmyna/services/live_quotes.py +295 -0
  191. marketmyna/services/macro.py +182 -0
  192. marketmyna/services/market_calendar.py +399 -0
  193. marketmyna/services/market_mood.py +168 -0
  194. marketmyna/services/market_valuation.py +231 -0
  195. marketmyna/services/mf_holdings.py +199 -0
  196. marketmyna/services/mf_insights.py +132 -0
  197. marketmyna/services/mf_lumpsum.py +587 -0
  198. marketmyna/services/mf_portfolio_ai.py +195 -0
  199. marketmyna/services/nse_events.py +98 -0
  200. marketmyna/services/overview.py +366 -0
  201. marketmyna/services/ownership.py +143 -0
  202. marketmyna/services/quality_scores.py +199 -0
  203. marketmyna/services/rates.py +335 -0
  204. marketmyna/services/reconcile.py +343 -0
  205. marketmyna/services/report.py +584 -0
  206. marketmyna/services/report_i18n.py +678 -0
  207. marketmyna/services/rotation.py +224 -0
  208. marketmyna/services/runtime_scheduler.py +548 -0
  209. marketmyna/services/schedule_config.py +251 -0
  210. marketmyna/services/schedule_time.py +39 -0
  211. marketmyna/services/screens.py +398 -0
  212. marketmyna/services/sec_mdna.py +173 -0
  213. marketmyna/services/shariah.py +246 -0
  214. marketmyna/services/sip.py +148 -0
  215. marketmyna/services/sip_days.py +362 -0
  216. marketmyna/services/smart_file.py +595 -0
  217. marketmyna/services/smart_pdf.py +425 -0
  218. marketmyna/services/smart_report.py +370 -0
  219. marketmyna/services/social_sentiment.py +511 -0
  220. marketmyna/services/thesis.py +328 -0
  221. marketmyna/services/translate.py +146 -0
  222. marketmyna/services/valuation.py +173 -0
  223. marketmyna/services/worker_pool.py +149 -0
  224. marketmyna/services/world_markets.py +208 -0
  225. marketmyna/storage.py +722 -0
  226. marketmyna/strategies/README.md +51 -0
  227. marketmyna/strategies/bottom_volume.yaml +49 -0
  228. marketmyna/strategies/box_oscillation.yaml +69 -0
  229. marketmyna/strategies/bull_trend.yaml +49 -0
  230. marketmyna/strategies/chan_theory.yaml +58 -0
  231. marketmyna/strategies/dragon_head.yaml +44 -0
  232. marketmyna/strategies/emotion_cycle.yaml +79 -0
  233. marketmyna/strategies/event_driven.yaml +54 -0
  234. marketmyna/strategies/expectation_repricing.yaml +54 -0
  235. marketmyna/strategies/growth_quality.yaml +54 -0
  236. marketmyna/strategies/hot_theme.yaml +54 -0
  237. marketmyna/strategies/ma_golden_cross.yaml +46 -0
  238. marketmyna/strategies/one_yang_three_yin.yaml +37 -0
  239. marketmyna/strategies/shrink_pullback.yaml +46 -0
  240. marketmyna/strategies/volume_breakout.yaml +47 -0
  241. marketmyna/strategies/wave_theory.yaml +64 -0
  242. marketmyna/trading/__init__.py +2 -0
  243. marketmyna/trading/alerts.py +356 -0
  244. marketmyna/trading/backtest.py +317 -0
  245. marketmyna/trading/charges.py +82 -0
  246. marketmyna/trading/chart.py +197 -0
  247. marketmyna/trading/indicators.py +303 -0
  248. marketmyna/trading/journal.py +74 -0
  249. marketmyna/trading/knn.py +164 -0
  250. marketmyna/trading/lots.py +109 -0
  251. marketmyna/trading/market_hours.py +66 -0
  252. marketmyna/trading/options.py +554 -0
  253. marketmyna/trading/pairs.py +94 -0
  254. marketmyna/trading/risk.py +170 -0
  255. marketmyna/trading/scanner.py +500 -0
  256. marketmyna/trading/volatility.py +163 -0
  257. marketmyna/utils/__init__.py +1 -0
  258. marketmyna/utils/deadline.py +36 -0
  259. marketmyna/utils/log_redact.py +52 -0
  260. marketmyna/utils/yahoo_gate.py +183 -0
  261. marketmyna/utils/yf_lock.py +12 -0
  262. marketmyna/web/apple-touch-icon.png +0 -0
  263. marketmyna/web/assets/Account-CA9NFDKO.js +1 -0
  264. marketmyna/web/assets/Analyze-uM0srB_U.js +7 -0
  265. marketmyna/web/assets/AreaChart-2i67V6Jf.js +1 -0
  266. marketmyna/web/assets/BarChart-_yngarVl.js +3 -0
  267. marketmyna/web/assets/Bot-E4gpf1qZ.js +1 -0
  268. marketmyna/web/assets/CandleChart-CioqTqMV.js +1 -0
  269. marketmyna/web/assets/Compare-D6_2CPl4.js +1 -0
  270. marketmyna/web/assets/ExportMenu-DaZ1xVmW.js +1 -0
  271. marketmyna/web/assets/LineChart-BaOkD1Ta.js +57 -0
  272. marketmyna/web/assets/Markets-BMw_M1sJ.js +1 -0
  273. marketmyna/web/assets/Portfolio-DQEdHVc2.js +1 -0
  274. marketmyna/web/assets/ReportView-BzhdcqSh.js +1 -0
  275. marketmyna/web/assets/Scheduler-I9PDKxTd.js +1 -0
  276. marketmyna/web/assets/Settings-DpXfHolW.js +3 -0
  277. marketmyna/web/assets/SipDays-C9q3bNSx.js +1 -0
  278. marketmyna/web/assets/SymbolSearch-CBOVPEk1.js +1 -0
  279. marketmyna/web/assets/Trade-_CM7HC4H.js +2 -0
  280. marketmyna/web/assets/TradeChart-ZjsycacC.js +1 -0
  281. marketmyna/web/assets/WatchButton-ComYW56_.js +1 -0
  282. marketmyna/web/assets/Watchlist-DAjLb0jd.js +1 -0
  283. marketmyna/web/assets/app-BP1o7WZS.js +1 -0
  284. marketmyna/web/assets/app-BkIUKdld.js +1 -0
  285. marketmyna/web/assets/app-CKeSYhEt.js +1 -0
  286. marketmyna/web/assets/app-CN1OERYX.js +1 -0
  287. marketmyna/web/assets/app-DIQllha5.js +1 -0
  288. marketmyna/web/assets/app-DeiAW0tX.js +1 -0
  289. marketmyna/web/assets/br-Cu5YU29T.svg +45 -0
  290. marketmyna/web/assets/browser-CR-ks2Fp.js +19 -0
  291. marketmyna/web/assets/browser-D5K_K_gj.js +1 -0
  292. marketmyna/web/assets/common-0wvpAVpd.js +1 -0
  293. marketmyna/web/assets/common-3ghy53Us.js +1 -0
  294. marketmyna/web/assets/common-Bib2unjX.js +1 -0
  295. marketmyna/web/assets/common-BvSJP0w_.js +1 -0
  296. marketmyna/web/assets/common-CSfyC0Sp.js +1 -0
  297. marketmyna/web/assets/common-C_t-iZPf.js +1 -0
  298. marketmyna/web/assets/compare-DMcsx70H.js +1 -0
  299. marketmyna/web/assets/es-d5m8M5h8.svg +544 -0
  300. marketmyna/web/assets/exporter-DiMg2O5d.js +3 -0
  301. marketmyna/web/assets/files-CpgiZRbN.js +1 -0
  302. marketmyna/web/assets/format-d8InM0Zw.js +9 -0
  303. marketmyna/web/assets/html2canvas-Bw0OShmD.js +5 -0
  304. marketmyna/web/assets/index-CKAnq-Or.js +12 -0
  305. marketmyna/web/assets/index-D4U8E6LH.css +2 -0
  306. marketmyna/web/assets/index.es-iBEw7jOv.js +5 -0
  307. marketmyna/web/assets/ir-cCIgaNf6.svg +219 -0
  308. marketmyna/web/assets/jspdf.es.min-C4285IdL.js +79 -0
  309. marketmyna/web/assets/jspdf.plugin.autotable-2eOJeXl-.js +2 -0
  310. marketmyna/web/assets/lightweight-charts.production-BF2noug5.js +1 -0
  311. marketmyna/web/assets/listings-BefgXpb1.js +1 -0
  312. marketmyna/web/assets/liveQuotes-CtSmCUFA.js +1 -0
  313. marketmyna/web/assets/markets-BI4AhI9W.js +1 -0
  314. marketmyna/web/assets/markets-BUHUdYpT.js +1 -0
  315. marketmyna/web/assets/markets-BqvN1Bvf.js +1 -0
  316. marketmyna/web/assets/markets-DPO40TTe.js +1 -0
  317. marketmyna/web/assets/markets-bsiPo-Tk.js +1 -0
  318. marketmyna/web/assets/markets-ff1e792u.js +1 -0
  319. marketmyna/web/assets/montecarlo-BsNlH2qe.js +1 -0
  320. marketmyna/web/assets/options-BYAZnwzW.js +1 -0
  321. marketmyna/web/assets/preload-helper-BLdHbfdN.js +1 -0
  322. marketmyna/web/assets/purify.es-Dqk5qvad.js +3 -0
  323. marketmyna/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  324. marketmyna/web/assets/sa-Dh79zbT9.svg +25 -0
  325. marketmyna/web/assets/toast-DDradTZw.js +1 -0
  326. marketmyna/web/assets/trade-fndw1Ql3.js +1 -0
  327. marketmyna/web/assets/ui-gsFb8B2m.js +1 -0
  328. marketmyna/web/assets/watchlists-B6YqwUIk.js +1 -0
  329. marketmyna/web/brand/logo-mark.webp +0 -0
  330. marketmyna/web/favicon.png +0 -0
  331. marketmyna/web/icon-192.png +0 -0
  332. marketmyna/web/icon-512.png +0 -0
  333. marketmyna/web/index.html +34 -0
  334. marketmyna/web/manifest.webmanifest +28 -0
  335. marketmyna/web/robots.txt +2 -0
  336. marketmyna/web/sw.js +40 -0
  337. marketmyna/web/theme-init.js +37 -0
  338. marketmyna-0.2.0.dist-info/METADATA +345 -0
  339. marketmyna-0.2.0.dist-info/RECORD +344 -0
  340. marketmyna-0.2.0.dist-info/WHEEL +5 -0
  341. marketmyna-0.2.0.dist-info/entry_points.txt +2 -0
  342. marketmyna-0.2.0.dist-info/licenses/LICENSE +202 -0
  343. marketmyna-0.2.0.dist-info/licenses/NOTICE +22 -0
  344. 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())