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,343 @@
1
+ """Portfolio reconciliation: your MarketMyna records against a statement.
2
+
3
+ Adapted from the GL-reconciler and statement-auditor workflows in
4
+ anthropics/financial-services (Apache-2.0): tie every position out against an
5
+ outside statement, list each break with the evidence and the most likely
6
+ cause, and propose a correcting entry that is only posted after the user
7
+ approves it. Two checks:
8
+
9
+ ledger the stored holdings against a replay of the transaction history
10
+ (they should always agree; a difference is internal drift)
11
+ statement the holdings against a broker holdings export (CSV / Excel from
12
+ Zerodha, Groww, Angel One, Upstox...) or a CAMS / KFintech CAS PDF
13
+
14
+ Causes, in the order they are tested: a split or bonus Yahoo knows about that
15
+ explains the exact ratio; a position only on one side (bought or sold outside
16
+ the records, IPO, gift, transfer); a quantity difference (a partial buy or
17
+ sale not recorded); and, for equal quantities, an average-cost difference
18
+ (brokers often include charges or use a different lot method) - reported for
19
+ information only.
20
+
21
+ The statement file is read in memory and never stored.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import csv
27
+ import io
28
+ import logging
29
+ import re
30
+ from datetime import date
31
+ from typing import Callable, Optional
32
+
33
+ import pandas as pd
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ QTY_TOL = 1e-3 # shares / units
38
+ COST_TOL = 0.02 # an average cost within 2% is a match
39
+ RATIO_TOL = 0.01
40
+
41
+ SYMBOL_COLS = ("symbol", "tradingsymbol", "trading symbol", "instrument", "scrip", "scrip code", "stock symbol",
42
+ "nse symbol", "ticker")
43
+ NAME_COLS = ("stock name", "company name", "company", "name", "security name", "scrip name", "stock")
44
+ QTY_COLS = ("quantity available", "quantity", "qty", "net qty", "total quantity", "holding qty", "holdings",
45
+ "shares", "units", "free qty", "available qty")
46
+ AVG_COLS = ("average price", "avg. cost", "avg cost", "average cost", "average buy price", "avg price",
47
+ "avg. price", "buy avg", "buy average", "average cost price")
48
+ ISIN_COLS = ("isin", "isin code")
49
+
50
+ ActionsFetch = Callable[[str, str, date], list[tuple[date, float]]]
51
+
52
+
53
+ class StatementError(ValueError):
54
+ pass
55
+
56
+
57
+ # ------------------------------------------------------------------ reading statements
58
+
59
+
60
+ def _norm(text) -> str:
61
+ return re.sub(r"\s+", " ", str(text or "").strip().lower())
62
+
63
+
64
+ def _pick(columns: list[str], wanted: tuple[str, ...]) -> Optional[str]:
65
+ cols = {_norm(c): c for c in columns}
66
+ for w in wanted:
67
+ if w in cols:
68
+ return cols[w]
69
+ for w in wanted: # "Quantity Available (T1)" and similar
70
+ for k, c in cols.items():
71
+ if k.startswith(w):
72
+ return c
73
+ return None
74
+
75
+
76
+ def _table(df: pd.DataFrame) -> pd.DataFrame:
77
+ """Broker exports often have a preamble (client id, dates) above the table:
78
+ find the first row that looks like the header."""
79
+ if _pick(list(df.columns), QTY_COLS):
80
+ return df
81
+ for i in range(min(len(df), 40)):
82
+ row = [str(v) for v in df.iloc[i].tolist()]
83
+ if _pick(row, QTY_COLS) and (_pick(row, SYMBOL_COLS) or _pick(row, NAME_COLS) or _pick(row, ISIN_COLS)):
84
+ out = df.iloc[i + 1:].copy()
85
+ out.columns = row
86
+ return out
87
+ raise StatementError("Couldn't find a holdings table (no quantity column) in this file.")
88
+
89
+
90
+ def _num(v) -> Optional[float]:
91
+ if v is None or (isinstance(v, float) and pd.isna(v)):
92
+ return None
93
+ try:
94
+ return float(str(v).replace(",", "").replace("₹", "").strip())
95
+ except ValueError:
96
+ return None
97
+
98
+
99
+ def read_table(filename: str, data: bytes) -> list[dict]:
100
+ """A broker holdings export -> [{symbol, name, isin, quantity, avg_cost}]."""
101
+ name = filename.lower()
102
+ try:
103
+ if name.endswith((".xlsx", ".xls")):
104
+ df = pd.read_excel(io.BytesIO(data), header=None, dtype=object)
105
+ df.columns = [f"c{i}" for i in range(len(df.columns))]
106
+ else:
107
+ # csv module, not pandas: a short preamble line ("Client ID,AB1234") would
108
+ # make pandas size the table to 2 columns and drop the real rows.
109
+ text = data.decode("utf-8-sig", errors="replace")
110
+ try:
111
+ dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t|")
112
+ except csv.Error:
113
+ dialect = csv.excel
114
+ lines = [r for r in csv.reader(io.StringIO(text), dialect) if any(c.strip() for c in r)]
115
+ width = max((len(r) for r in lines), default=0)
116
+ df = pd.DataFrame([r + [""] * (width - len(r)) for r in lines], columns=[f"c{i}" for i in range(width)])
117
+ except StatementError:
118
+ raise
119
+ except Exception as exc:
120
+ raise StatementError(f"Couldn't read this file ({type(exc).__name__}). Upload a CSV or Excel holdings export.") from exc
121
+ df = _table(df)
122
+ cols = list(df.columns)
123
+ sym_c, name_c, isin_c = _pick(cols, SYMBOL_COLS), _pick(cols, NAME_COLS), _pick(cols, ISIN_COLS)
124
+ qty_c, avg_c = _pick(cols, QTY_COLS), _pick(cols, AVG_COLS)
125
+ rows = []
126
+ for _, r in df.iterrows():
127
+ qty = _num(r.get(qty_c))
128
+ sym = str(r.get(sym_c) or "").strip() if sym_c else ""
129
+ nm = str(r.get(name_c) or "").strip() if name_c else ""
130
+ isin = str(r.get(isin_c) or "").strip().upper() if isin_c else ""
131
+ if qty is None or not (sym or nm or isin) or _norm(sym or nm) in ("total", "grand total"):
132
+ continue
133
+ rows.append({"symbol": sym, "name": nm, "isin": isin if re.fullmatch(r"[A-Z]{2}[A-Z0-9]{9}\d", isin) else "",
134
+ "quantity": qty, "avg_cost": _num(r.get(avg_c)) if avg_c else None})
135
+ if not rows:
136
+ raise StatementError("The file has a holdings table but no rows with a quantity.")
137
+ return rows
138
+
139
+
140
+ def read_cas(data: bytes, password: str) -> list[dict]:
141
+ """A CAMS / KFintech CAS PDF -> one row per scheme (AMFI code as the symbol)."""
142
+ from .mf_holdings import CASError, parse_cas
143
+
144
+ try:
145
+ cas = parse_cas(data, password)
146
+ except CASError as exc:
147
+ raise StatementError(str(exc)) from exc
148
+ return [{"symbol": str(h["amfi"]), "name": h["scheme"], "isin": h.get("isin") or "", "quantity": h["units"],
149
+ "avg_cost": (h["cost"] / h["units"]) if h.get("cost") and h["units"] else None, "market": "mf",
150
+ "resolved": True} for h in cas["holdings"]]
151
+
152
+
153
+ def read_statement(filename: str, data: bytes, password: str = "") -> tuple[str, list[dict]]:
154
+ """-> (kind, rows): kind "cas" (mutual funds) or "broker" (Indian equities)."""
155
+ if data.startswith(b"%PDF"):
156
+ return "cas", read_cas(data, password)
157
+ return "broker", read_table(filename, data)
158
+
159
+
160
+ # ------------------------------------------------------------------ matching
161
+
162
+
163
+ def _clean_symbol(s: str) -> str:
164
+ s = s.strip().upper()
165
+ s = re.sub(r"^(NSE|BSE)[:\s]+", "", s)
166
+ return re.sub(r"(-EQ|-BE|-BZ|\.NS|\.BO)$", "", s)
167
+
168
+
169
+ def resolve(rows: list[dict], listings: list[dict]) -> tuple[list[dict], list[dict]]:
170
+ """Give each broker row its NSE symbol: by symbol, then ISIN, then exact name."""
171
+ by_symbol = {str(r["symbol"]).upper(): r for r in listings}
172
+ by_isin = {r["isin"]: r for r in listings if r.get("isin")}
173
+ by_name = {_norm(r.get("name")): r for r in listings if r.get("name")}
174
+ done, unknown = [], []
175
+ for row in rows:
176
+ if row.get("resolved"):
177
+ done.append(row)
178
+ continue
179
+ hit = by_symbol.get(_clean_symbol(row["symbol"])) if row["symbol"] else None
180
+ hit = hit or (by_isin.get(row["isin"]) if row["isin"] else None)
181
+ hit = hit or (by_name.get(_norm(row["name"])) if row["name"] else None)
182
+ if hit is None:
183
+ unknown.append(row)
184
+ continue
185
+ done.append({**row, "symbol": str(hit["symbol"]).upper(), "name": row["name"] or hit.get("name", ""),
186
+ "market": "india"})
187
+ return done, unknown
188
+
189
+
190
+ def _yahoo_actions(symbol: str, market: str, since: date) -> list[tuple[date, float]]:
191
+ """Splits and bonuses (Yahoo reports bonuses as splits: 1:1 bonus = 2.0) since a date."""
192
+ import yfinance as yf
193
+
194
+ from .analysis_run import yahoo_symbol
195
+
196
+ s = yf.Ticker(yahoo_symbol(symbol, market)).splits
197
+ if s is None or s.empty:
198
+ return []
199
+ out = []
200
+ for when, ratio in s.items():
201
+ d = pd.Timestamp(when).date()
202
+ if d >= since and ratio and ratio > 0:
203
+ out.append((d, float(ratio)))
204
+ return out
205
+
206
+
207
+ def _ratio_text(r: float) -> str:
208
+ """2.0 -> "×2 (each share became 2)": unambiguous, unlike "1:1 bonus" vs "1:2"."""
209
+ return f"×{r:g} (each share became {r:g})"
210
+
211
+
212
+ def _explained_by_actions(app_qty: float, stmt_qty: float, actions: list[tuple[date, float]]) -> Optional[list[tuple[date, float]]]:
213
+ """The corporate actions (in order) whose combined ratio turns app_qty into stmt_qty."""
214
+ if app_qty <= 0 or not actions:
215
+ return None
216
+ target = stmt_qty / app_qty
217
+ total = 1.0
218
+ for i, (_, r) in enumerate(actions):
219
+ total *= r
220
+ if abs(total - target) <= RATIO_TOL * target:
221
+ return actions[: i + 1]
222
+ return None
223
+
224
+
225
+ def compare(app: dict[str, dict], stmt: list[dict], actions: Optional[ActionsFetch] = _yahoo_actions,
226
+ today: Optional[date] = None) -> dict:
227
+ """app: symbol -> {quantity, avg_cost, market, current_price, first_date}; stmt rows are resolved.
228
+
229
+ Returns matches, breaks (each with cause and a proposed fix) and cost differences."""
230
+ today = today or date.today()
231
+ stmt_by = {}
232
+ for r in stmt: # a symbol in several rows (two demat lines, two folios) is one position
233
+ cur = stmt_by.get(r["symbol"])
234
+ if cur is None:
235
+ stmt_by[r["symbol"]] = dict(r)
236
+ else:
237
+ q = cur["quantity"] + r["quantity"]
238
+ if cur.get("avg_cost") is not None and r.get("avg_cost") is not None and q:
239
+ cur["avg_cost"] = (cur["avg_cost"] * cur["quantity"] + r["avg_cost"] * r["quantity"]) / q
240
+ cur["quantity"] = q
241
+ matches, breaks, costs = [], [], []
242
+ note = f"Reconciliation {today.isoformat()}"
243
+ for sym in sorted(set(app) | set(stmt_by)):
244
+ a, s = app.get(sym), stmt_by.get(sym)
245
+ market = (s or {}).get("market") or (a or {}).get("market") or "india"
246
+ base = {"symbol": sym, "market": market, "name": (s or {}).get("name") or "",
247
+ "statement_qty": s["quantity"] if s else 0.0, "app_qty": a["quantity"] if a else 0.0,
248
+ "statement_avg": (s or {}).get("avg_cost"), "app_avg": (a or {}).get("avg_cost")}
249
+ base["diff"] = base["statement_qty"] - base["app_qty"]
250
+ price_now = (a or {}).get("current_price") or (s or {}).get("avg_cost") or 0.0
251
+
252
+ if s and s["quantity"] > QTY_TOL and (not a or a["quantity"] <= QTY_TOL):
253
+ breaks.append({**base, "kind": "not_in_app",
254
+ "cause": "On the statement but not in your records: a buy not recorded here, "
255
+ "or shares from an IPO, gift or transfer-in.",
256
+ "fix": {"type": "buy", "quantity": s["quantity"], "price": s.get("avg_cost") or price_now,
257
+ "date": today.isoformat(), "notes": note, "needs_price": not s.get("avg_cost")}})
258
+ continue
259
+ if a and a["quantity"] > QTY_TOL and (not s or s["quantity"] <= QTY_TOL):
260
+ breaks.append({**base, "kind": "not_in_statement",
261
+ "cause": "In your records but not on the statement: sold (not recorded here), or held "
262
+ "in another demat account or folio.",
263
+ "fix": {"type": "sell", "quantity": a["quantity"], "price": price_now,
264
+ "date": today.isoformat(), "notes": note, "needs_price": True}})
265
+ continue
266
+ if not a or not s:
267
+ continue
268
+ tol = max(QTY_TOL, a["quantity"] * 1e-5) if market == "mf" else QTY_TOL # fund units carry 3 decimals
269
+ if abs(base["diff"]) <= tol:
270
+ matches.append(base)
271
+ sa, aa = s.get("avg_cost"), a.get("avg_cost")
272
+ if sa and aa and abs(sa / aa - 1) > COST_TOL:
273
+ costs.append({**base, "cause": "Same quantity, different average cost: brokers often add charges "
274
+ "to the cost or use a different lot method. Nothing to fix."})
275
+ continue
276
+ found = None
277
+ if actions and market != "mf":
278
+ try:
279
+ since = a.get("first_date") or date(2000, 1, 1)
280
+ found = _explained_by_actions(a["quantity"], s["quantity"], actions(sym, market, since))
281
+ except Exception as exc:
282
+ logger.info("Corporate actions for %s unavailable: %s", sym, exc)
283
+ if found:
284
+ labels = ", ".join(f"{_ratio_text(r)} on {d.isoformat()}" for d, r in found)
285
+ breaks.append({**base, "kind": "split",
286
+ "cause": f"A split / bonus ({labels}) is not recorded: it explains the difference exactly.",
287
+ "fixes": [{"type": "split", "quantity": r, "price": 0.0, "date": d.isoformat(),
288
+ "notes": f"Split / bonus ×{r:g} (reconciliation)"} for d, r in found]})
289
+ continue
290
+ if base["diff"] > 0:
291
+ # cost of the missing lot, from the statement's total cost less what is recorded
292
+ lot = None
293
+ if s.get("avg_cost") and a.get("avg_cost"):
294
+ lot = (s["quantity"] * s["avg_cost"] - a["quantity"] * a["avg_cost"]) / base["diff"]
295
+ breaks.append({**base, "kind": "short",
296
+ "cause": f"The statement shows {base['diff']:g} more: a buy not recorded here.",
297
+ "fix": {"type": "buy", "quantity": base["diff"], "price": lot if lot and lot > 0 else price_now,
298
+ "date": today.isoformat(), "notes": note, "needs_price": not (lot and lot > 0)}})
299
+ else:
300
+ breaks.append({**base, "kind": "excess",
301
+ "cause": f"Your records show {-base['diff']:g} more: a sale not recorded here, or part "
302
+ "held in another account.",
303
+ "fix": {"type": "sell", "quantity": -base["diff"], "price": price_now,
304
+ "date": today.isoformat(), "notes": note, "needs_price": True}})
305
+ for b in breaks: # one shape for the client: always a list of fixes
306
+ if "fix" in b:
307
+ b["fixes"] = [b.pop("fix")]
308
+ return {"matches": matches, "breaks": breaks, "cost_differences": costs}
309
+
310
+
311
+ # ------------------------------------------------------------------ ledger check
312
+
313
+
314
+ def ledger_check(portfolio) -> list[dict]:
315
+ """Stored holdings vs a replay of the transaction history."""
316
+ from ..portfolio.manager import PortfolioManager
317
+
318
+ try:
319
+ # recorded order, as the portfolio itself applies them
320
+ replayed = PortfolioManager._replay(list(portfolio.transactions), {})
321
+ except ValueError as exc:
322
+ return [{"symbol": None, "issue": f"The transaction history doesn't replay cleanly: {exc}"}]
323
+ out = []
324
+ for sym in sorted(set(replayed) | set(portfolio.holdings)):
325
+ h, r = portfolio.holdings.get(sym), replayed.get(sym)
326
+ hq, rq = (h.quantity if h else 0.0), (r.quantity if r else 0.0)
327
+ if abs(hq - rq) > QTY_TOL:
328
+ out.append({"symbol": sym, "stored": hq, "from_history": rq,
329
+ "issue": "Holding and transaction history disagree" if h and r else
330
+ ("Holding with no transactions behind it" if h else "Transactions with no holding")})
331
+ return out
332
+
333
+
334
+ def holdings_for(portfolio, kind: str) -> dict[str, dict]:
335
+ """The positions a statement of this kind covers: CAS -> funds, broker -> Indian equities."""
336
+ want = {"cas": ("mf",), "broker": ("india",)}[kind]
337
+ first: dict[str, date] = {}
338
+ for t in portfolio.transactions:
339
+ if t.date and (t.symbol not in first or t.date.date() < first[t.symbol]):
340
+ first[t.symbol] = t.date.date()
341
+ return {sym: {"quantity": h.quantity, "avg_cost": h.avg_cost, "market": (h.market or "").lower(),
342
+ "current_price": h.current_price, "first_date": first.get(sym)}
343
+ for sym, h in portfolio.holdings.items() if (h.market or "").lower() in want}