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