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,362 @@
|
|
|
1
|
+
"""Historically best SIP day for every Indian mutual fund and every listed ETF.
|
|
2
|
+
|
|
3
|
+
For one price series, replays a monthly SIP on each day 1-28 of the month
|
|
4
|
+
over the last 3, 5 and 10 years and ranks the days by XIRR (same rules as
|
|
5
|
+
services/sip.py: buy on the first trading day on or after the chosen day,
|
|
6
|
+
value everything at the last price). All 28 days are solved together with
|
|
7
|
+
numpy, so a fund costs a few milliseconds once its history is downloaded.
|
|
8
|
+
|
|
9
|
+
The edge is usually small and often down to chance, so each result carries
|
|
10
|
+
a label rather than a recommendation:
|
|
11
|
+
- "consistent": the best day of every window lands within a few days of
|
|
12
|
+
the others (needs 5+ years of history, i.e. two windows or more).
|
|
13
|
+
- "no_edge": the windows disagree; any day is as good as another.
|
|
14
|
+
- "short": only 3-5 years of history, too short to check consistency.
|
|
15
|
+
- "negligible": best and worst day differ by under MIN_GAP points a year
|
|
16
|
+
over the longest window (typical of debt funds, whose smooth NAV makes a
|
|
17
|
+
month-end day "win" every time by a meaningless margin).
|
|
18
|
+
|
|
19
|
+
A background loop (refresh_loop) keeps a per-market summary on disk under
|
|
20
|
+
~/.marketmyna/sip_days/<market>.json, refreshing entries older than a week:
|
|
21
|
+
Indian schemes from mfapi.in NAVs, ETFs (India, US, Hong Kong) from Yahoo
|
|
22
|
+
daily closes adjusted for dividends. Past behaviour, not advice.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
32
|
+
from datetime import date, datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
import pandas as pd
|
|
38
|
+
|
|
39
|
+
from marketmyna.core.home import app_home
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
DAYS = np.arange(1, 29)
|
|
44
|
+
WINDOWS = (3, 5, 10) # years
|
|
45
|
+
NEAR_DAYS = 3 # best days this close count as the same answer
|
|
46
|
+
MIN_GAP = 0.10 # points a year; below this the day does not matter
|
|
47
|
+
MAX_AGE_DAYS = 7
|
|
48
|
+
ETF_MARKETS = ("india", "us", "hong_kong")
|
|
49
|
+
STALE_NAV_DAYS = 30 # a scheme with no NAV for a month is wound up / merged
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------ maths
|
|
53
|
+
|
|
54
|
+
def _buy_positions(index: pd.DatetimeIndex, start: pd.Timestamp) -> tuple[np.ndarray, np.ndarray]:
|
|
55
|
+
"""Index positions of each day's monthly buys -> (pos[28, M], valid[28, M])."""
|
|
56
|
+
end = index[-1]
|
|
57
|
+
months = pd.date_range(pd.Timestamp(start.year, start.month, 1), end, freq="MS")
|
|
58
|
+
targets = months.values[None, :] + (DAYS[:, None] - 1).astype("timedelta64[D]")
|
|
59
|
+
pos = index.values.searchsorted(targets.ravel()).reshape(targets.shape)
|
|
60
|
+
inside = pos < len(index)
|
|
61
|
+
safe = np.minimum(pos, len(index) - 1)
|
|
62
|
+
bought = index.values[safe]
|
|
63
|
+
same_month = bought.astype("datetime64[M]") == targets.astype("datetime64[M]")
|
|
64
|
+
valid = inside & same_month & (bought >= np.datetime64(start))
|
|
65
|
+
return safe, valid
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def day_xirrs(nav: pd.Series, start: pd.Timestamp) -> np.ndarray:
|
|
69
|
+
"""XIRR (fraction a year) of a monthly SIP from `start` for days 1..28;
|
|
70
|
+
NaN where a day has fewer than two instalments or no solution."""
|
|
71
|
+
index = pd.DatetimeIndex(nav.index)
|
|
72
|
+
prices = nav.to_numpy(dtype=float)
|
|
73
|
+
pos, valid = _buy_positions(index, start)
|
|
74
|
+
count = valid.sum(axis=1)
|
|
75
|
+
units = np.where(valid, 1.0 / prices[pos], 0.0).sum(axis=1)
|
|
76
|
+
value = units * prices[-1]
|
|
77
|
+
|
|
78
|
+
days_of = index.values[pos].astype("datetime64[D]").astype(np.int64)
|
|
79
|
+
first = np.where(valid, days_of, np.iinfo(np.int64).max).min(axis=1)
|
|
80
|
+
t = np.where(valid, (days_of - first[:, None]) / 365.0, 0.0)
|
|
81
|
+
t_end = (np.int64(index.values[-1].astype("datetime64[D]").astype(np.int64)) - first) / 365.0
|
|
82
|
+
|
|
83
|
+
def npv(rate: np.ndarray) -> np.ndarray:
|
|
84
|
+
base = (1.0 + rate)[:, None]
|
|
85
|
+
outflow = np.where(valid, base ** (-t), 0.0).sum(axis=1)
|
|
86
|
+
return value * (1.0 + rate) ** (-t_end) - outflow
|
|
87
|
+
|
|
88
|
+
lo = np.full(len(DAYS), -0.99)
|
|
89
|
+
hi = np.full(len(DAYS), 10.0)
|
|
90
|
+
ok = (count >= 2) & (npv(lo) * npv(hi) <= 0)
|
|
91
|
+
for _ in range(80):
|
|
92
|
+
mid = (lo + hi) / 2
|
|
93
|
+
left = npv(lo) * npv(mid) <= 0
|
|
94
|
+
hi = np.where(left, mid, hi)
|
|
95
|
+
lo = np.where(left, lo, mid)
|
|
96
|
+
return np.where(ok, (lo + hi) / 2, np.nan)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clean(nav: pd.Series) -> pd.Series:
|
|
100
|
+
"""Sorted, positive, de-duplicated prices without one-off bad prints.
|
|
101
|
+
|
|
102
|
+
Yahoo sometimes keeps a day or two at a tenth of the real price around a
|
|
103
|
+
split (NIFTYBEES, Dec 2019); a price more than 40% away from the median of
|
|
104
|
+
the surrounding 11 days is dropped. Real crashes move far less in a day.
|
|
105
|
+
"""
|
|
106
|
+
nav = nav.dropna()
|
|
107
|
+
nav = nav[nav > 0].sort_index()
|
|
108
|
+
nav = nav[~nav.index.duplicated(keep="last")]
|
|
109
|
+
if len(nav) < 11:
|
|
110
|
+
return nav
|
|
111
|
+
ratio = nav / nav.rolling(11, center=True, min_periods=3).median()
|
|
112
|
+
return nav[(ratio < 1.4) & (ratio > 1 / 1.4)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _near(a: int, b: int) -> bool:
|
|
116
|
+
gap = abs(a - b)
|
|
117
|
+
return min(gap, 30 - gap) <= NEAR_DAYS # day 28 sits next to next month's 1st
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def label(windows: dict) -> str:
|
|
121
|
+
"""consistent / no_edge / short / negligible for a {"3y": {...}, ...} map."""
|
|
122
|
+
longest = windows[list(windows)[-1]]
|
|
123
|
+
bests = [w["best"] for w in windows.values()]
|
|
124
|
+
if longest["spread"] < MIN_GAP:
|
|
125
|
+
return "negligible"
|
|
126
|
+
if len(bests) < 2:
|
|
127
|
+
return "short"
|
|
128
|
+
if all(_near(a, b) for a in bests for b in bests):
|
|
129
|
+
return "consistent"
|
|
130
|
+
return "no_edge"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def analyze(nav: pd.Series) -> Optional[dict]:
|
|
134
|
+
"""Best / worst SIP day per window plus a consistency label, or None when
|
|
135
|
+
there are under 3 years of prices."""
|
|
136
|
+
nav = clean(nav)
|
|
137
|
+
if len(nav) < 30:
|
|
138
|
+
return None
|
|
139
|
+
end = nav.index[-1]
|
|
140
|
+
windows = {}
|
|
141
|
+
for years in WINDOWS:
|
|
142
|
+
start = end - pd.DateOffset(years=years)
|
|
143
|
+
if nav.index[0] > start + pd.Timedelta(days=31):
|
|
144
|
+
continue # not enough history for a full window
|
|
145
|
+
x = day_xirrs(nav, start)
|
|
146
|
+
if np.isnan(x).all():
|
|
147
|
+
continue
|
|
148
|
+
best, worst = int(np.nanargmax(x)), int(np.nanargmin(x))
|
|
149
|
+
windows[f"{years}y"] = {
|
|
150
|
+
"best": int(DAYS[best]),
|
|
151
|
+
"worst": int(DAYS[worst]),
|
|
152
|
+
"best_xirr": round(float(x[best]) * 100, 2),
|
|
153
|
+
"spread": round(float(x[best] - x[worst]) * 100, 2),
|
|
154
|
+
}
|
|
155
|
+
if not windows:
|
|
156
|
+
return None
|
|
157
|
+
longest = windows[list(windows)[-1]]
|
|
158
|
+
return {
|
|
159
|
+
"as_of": str(end.date()),
|
|
160
|
+
"best_day": longest["best"],
|
|
161
|
+
"spread": longest["spread"],
|
|
162
|
+
"edge": label(windows),
|
|
163
|
+
"windows": windows,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def curve(nav: pd.Series, years: int) -> list[dict]:
|
|
168
|
+
"""XIRR % for each day 1..28 over one window (the fund page's chart)."""
|
|
169
|
+
nav = clean(nav)
|
|
170
|
+
if len(nav) < 30:
|
|
171
|
+
return []
|
|
172
|
+
start = max(nav.index[-1] - pd.DateOffset(years=years), nav.index[0])
|
|
173
|
+
x = day_xirrs(nav, start)
|
|
174
|
+
return [{"day": int(d), "xirr_pct": None if np.isnan(v) else round(float(v) * 100, 3)} for d, v in zip(DAYS, x)]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------ storage
|
|
178
|
+
|
|
179
|
+
_lock = threading.Lock()
|
|
180
|
+
_memory: dict[str, dict] = {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _root() -> Path:
|
|
184
|
+
p = app_home() / "sip_days"
|
|
185
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
return p
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def load(market: str) -> dict:
|
|
190
|
+
"""symbol -> summary for one market (cached in memory)."""
|
|
191
|
+
with _lock:
|
|
192
|
+
if market in _memory:
|
|
193
|
+
return _memory[market]
|
|
194
|
+
f = _root() / f"{market}.json"
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(f.read_text(encoding="utf-8")) if f.exists() else {}
|
|
197
|
+
except (OSError, ValueError) as exc:
|
|
198
|
+
logger.warning("SIP days %s unreadable: %s", market, exc)
|
|
199
|
+
data = {}
|
|
200
|
+
for entry in data.values(): # labels follow the current rules, no refetch needed
|
|
201
|
+
if entry.get("windows"):
|
|
202
|
+
entry["edge"] = label(entry["windows"])
|
|
203
|
+
with _lock:
|
|
204
|
+
_memory[market] = data
|
|
205
|
+
return data
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _save(market: str, data: dict) -> None:
|
|
209
|
+
f = _root() / f"{market}.json"
|
|
210
|
+
tmp = f.with_suffix(".tmp")
|
|
211
|
+
tmp.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
|
|
212
|
+
tmp.replace(f)
|
|
213
|
+
with _lock:
|
|
214
|
+
_memory[market] = data
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def annotate(rows: list[dict], market: str) -> None:
|
|
218
|
+
"""Adds sip_day / sip_edge / sip_spread to mutual fund and ETF rows in place."""
|
|
219
|
+
markets = ("india", *ETF_MARKETS) if market == "all" else (market,)
|
|
220
|
+
tables = {m: load(m) for m in set(markets)}
|
|
221
|
+
for row in rows:
|
|
222
|
+
if row.get("type") not in ("mf", "etf"):
|
|
223
|
+
continue
|
|
224
|
+
hit = tables.get(row.get("market"), {}).get(str(row.get("symbol")))
|
|
225
|
+
if hit and hit.get("best_day"):
|
|
226
|
+
row["sip_day"] = hit["best_day"]
|
|
227
|
+
row["sip_edge"] = hit["edge"]
|
|
228
|
+
row["sip_spread"] = hit["spread"]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------ fetching
|
|
232
|
+
|
|
233
|
+
def mf_nav(code: str) -> Optional[pd.Series]:
|
|
234
|
+
from ..data.mutual_funds import MutualFundDataFetcher
|
|
235
|
+
|
|
236
|
+
df = MutualFundDataFetcher().get_historical_nav(int(code), days=100_000)
|
|
237
|
+
return None if df.empty else df["nav"].astype(float)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _yahoo_closes(tickers: list[str]) -> dict[str, pd.Series]:
|
|
241
|
+
import yfinance as yf
|
|
242
|
+
|
|
243
|
+
from ..utils.yf_lock import YF_DOWNLOAD_LOCK
|
|
244
|
+
|
|
245
|
+
start = (pd.Timestamp.today() - pd.DateOffset(years=max(WINDOWS)) - pd.Timedelta(days=45)).date()
|
|
246
|
+
with YF_DOWNLOAD_LOCK:
|
|
247
|
+
df = yf.download(tickers, start=str(start), interval="1d", progress=False, auto_adjust=True, threads=True)
|
|
248
|
+
if df is None or df.empty:
|
|
249
|
+
return {}
|
|
250
|
+
closes = df["Close"] if isinstance(df.columns, pd.MultiIndex) else df[["Close"]].rename(columns={"Close": tickers[0]})
|
|
251
|
+
out = {}
|
|
252
|
+
for t in tickers:
|
|
253
|
+
if t in closes:
|
|
254
|
+
s = closes[t].dropna()
|
|
255
|
+
if len(s):
|
|
256
|
+
idx = pd.DatetimeIndex(s.index)
|
|
257
|
+
s.index = (idx.tz_localize(None) if idx.tz is not None else idx).normalize()
|
|
258
|
+
out[t] = s
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def etf_prices(symbol: str, market: str) -> Optional[pd.Series]:
|
|
263
|
+
from .analysis_run import yahoo_symbol
|
|
264
|
+
|
|
265
|
+
t = yahoo_symbol(symbol, market)
|
|
266
|
+
return _yahoo_closes([t]).get(t)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _fresh(entry: Optional[dict], today: date) -> bool:
|
|
270
|
+
if not entry:
|
|
271
|
+
return False
|
|
272
|
+
try:
|
|
273
|
+
checked = datetime.fromisoformat(entry["checked"]).date()
|
|
274
|
+
except (KeyError, ValueError):
|
|
275
|
+
return False
|
|
276
|
+
return (today - checked).days < MAX_AGE_DAYS
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _targets(market: str, rows: list[dict]) -> list[str]:
|
|
280
|
+
want = "mf" if market == "india_mf" else "etf"
|
|
281
|
+
return [str(r["symbol"]) for r in rows if r.get("type") == want and r.get("symbol")]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _record(series: Optional[pd.Series], today: date) -> dict:
|
|
285
|
+
entry: dict = {"checked": today.isoformat()}
|
|
286
|
+
if series is None or series.empty:
|
|
287
|
+
entry["status"] = "no_data"
|
|
288
|
+
return entry
|
|
289
|
+
if (pd.Timestamp(today) - pd.DatetimeIndex(series.index).max()).days > STALE_NAV_DAYS:
|
|
290
|
+
entry["status"] = "inactive"
|
|
291
|
+
return entry
|
|
292
|
+
result = analyze(series)
|
|
293
|
+
if result is None:
|
|
294
|
+
entry["status"] = "too_new"
|
|
295
|
+
return entry
|
|
296
|
+
entry.update(result)
|
|
297
|
+
return entry
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def refresh_mutual_funds(workers: int = 4, save_every: int = 200) -> int:
|
|
301
|
+
"""Indian schemes (every plan and option). Returns how many were updated."""
|
|
302
|
+
from ..data.listings import get_store
|
|
303
|
+
|
|
304
|
+
today = date.today()
|
|
305
|
+
data = dict(load("india"))
|
|
306
|
+
codes = [c for c in _targets("india_mf", get_store().rows("india")) if not _fresh(data.get(c), today)]
|
|
307
|
+
done = 0
|
|
308
|
+
|
|
309
|
+
def one(code: str) -> tuple[str, dict]:
|
|
310
|
+
try:
|
|
311
|
+
return code, _record(mf_nav(code), today)
|
|
312
|
+
except Exception as exc: # one bad scheme must not stop the run
|
|
313
|
+
logger.debug("SIP days %s: %s", code, exc)
|
|
314
|
+
return code, {"checked": today.isoformat(), "status": "error"}
|
|
315
|
+
|
|
316
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
317
|
+
for code, entry in pool.map(one, codes):
|
|
318
|
+
data[code] = entry
|
|
319
|
+
done += 1
|
|
320
|
+
if done % save_every == 0:
|
|
321
|
+
_save("india", data)
|
|
322
|
+
if done:
|
|
323
|
+
_save("india", data)
|
|
324
|
+
return done
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def refresh_etfs(batch: int = 40) -> int:
|
|
328
|
+
from ..data.listings import get_store
|
|
329
|
+
from .analysis_run import yahoo_symbol
|
|
330
|
+
|
|
331
|
+
today = date.today()
|
|
332
|
+
total = 0
|
|
333
|
+
for market in ETF_MARKETS:
|
|
334
|
+
data = dict(load(market))
|
|
335
|
+
symbols = [s for s in _targets(market, get_store().rows(market)) if not _fresh(data.get(s), today)]
|
|
336
|
+
for i in range(0, len(symbols), batch):
|
|
337
|
+
chunk = symbols[i : i + batch]
|
|
338
|
+
tickers = {yahoo_symbol(s, market): s for s in chunk}
|
|
339
|
+
try:
|
|
340
|
+
closes = _yahoo_closes(list(tickers))
|
|
341
|
+
except Exception as exc:
|
|
342
|
+
logger.warning("SIP days ETF batch (%s): %s", market, exc)
|
|
343
|
+
continue
|
|
344
|
+
for t, s in tickers.items():
|
|
345
|
+
data[s] = _record(closes.get(t), today)
|
|
346
|
+
total += len(chunk)
|
|
347
|
+
_save(market, data)
|
|
348
|
+
time.sleep(1) # be gentle with Yahoo
|
|
349
|
+
return total
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def refresh_loop(first_delay: int = 300, every: int = 6 * 3600) -> None:
|
|
353
|
+
"""Background thread: fill and then keep the summaries fresh."""
|
|
354
|
+
time.sleep(first_delay) # after start-up and the listing warm-up
|
|
355
|
+
while True:
|
|
356
|
+
try:
|
|
357
|
+
n = refresh_mutual_funds()
|
|
358
|
+
m = refresh_etfs()
|
|
359
|
+
logger.info("SIP days refreshed: %d funds, %d ETFs", n, m)
|
|
360
|
+
except Exception as exc: # never kill the thread
|
|
361
|
+
logger.warning("SIP days refresh: %s", exc)
|
|
362
|
+
time.sleep(every)
|