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,587 @@
|
|
|
1
|
+
"""Lumpsum window check for a mutual fund: is today's dip unusual for this
|
|
2
|
+
fund, and what happened after similar dips before?
|
|
3
|
+
|
|
4
|
+
No fixed threshold: every condition is measured against the fund's own
|
|
5
|
+
history (up to 10 years of daily NAV from mfapi.in).
|
|
6
|
+
|
|
7
|
+
Conditions (each pass / fail, shown with its numbers):
|
|
8
|
+
- dip: NAV is further below its highest NAV than on 75% of past days;
|
|
9
|
+
- trend: NAV is under its 200-day average;
|
|
10
|
+
- history: after past dips at least this deep, the median 1-year return was
|
|
11
|
+
better than the fund's median 1-year return from any day;
|
|
12
|
+
- market: the Nifty 50 is also further below its high than on 60% of past days.
|
|
13
|
+
|
|
14
|
+
A "turning" note says whether the NAV has started to rise again (10-day
|
|
15
|
+
change above zero while still below its average). The level is the number of
|
|
16
|
+
conditions met: 3-4 including an unusual dip of the fund itself is strong, 2 or
|
|
17
|
+
more is watch, otherwise none. Wording (lumpsum
|
|
18
|
+
"opportunity" vs a neutral "dip check") is chosen by the caller from the
|
|
19
|
+
compliance mode; this module only reports facts.
|
|
20
|
+
|
|
21
|
+
Equity, hybrid and index/ETF equity funds only: debt and liquid NAVs barely
|
|
22
|
+
dip, so the check would say nothing useful there.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
|
|
35
|
+
from marketmyna.core.home import app_home
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
YEARS = 10
|
|
40
|
+
TRADING_DAYS = 252
|
|
41
|
+
DIP_PCTL = 75 # deeper than this share of past days = an unusual dip
|
|
42
|
+
MARKET_PCTL = 60
|
|
43
|
+
MIN_DIP = -0.03 # under 3% below the high is not a dip worth checking
|
|
44
|
+
CACHE_SECONDS = 6 * 3600
|
|
45
|
+
CHECKED_CLASSES = ("Equity", "Hybrid")
|
|
46
|
+
|
|
47
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
48
|
+
_lock = threading.Lock()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _drawdown(s: pd.Series) -> pd.Series:
|
|
52
|
+
return s / s.cummax() - 1.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _rsi(s: pd.Series, n: int = 14) -> Optional[float]:
|
|
56
|
+
d = s.diff().dropna()
|
|
57
|
+
if len(d) < n + 1:
|
|
58
|
+
return None
|
|
59
|
+
up = d.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean().iloc[-1]
|
|
60
|
+
down = (-d.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean().iloc[-1]
|
|
61
|
+
return 100.0 if down == 0 else float(100 - 100 / (1 + up / down))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _recovery_days(nav: pd.Series, depth: float) -> Optional[float]:
|
|
65
|
+
"""Median trading days from first reaching `depth` below the high to
|
|
66
|
+
getting back to that high, over past completed episodes that deep."""
|
|
67
|
+
dd = _drawdown(nav).to_numpy()
|
|
68
|
+
days, start, hit = [], None, None
|
|
69
|
+
for i, v in enumerate(dd):
|
|
70
|
+
if v < 0 and start is None:
|
|
71
|
+
start = i
|
|
72
|
+
if start is not None and hit is None and v <= depth:
|
|
73
|
+
hit = i
|
|
74
|
+
if v >= 0 and start is not None:
|
|
75
|
+
if hit is not None:
|
|
76
|
+
days.append(i - hit)
|
|
77
|
+
start, hit = None, None
|
|
78
|
+
return float(np.median(days)) if days else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dip_stats(series: pd.Series) -> Optional[dict]:
|
|
82
|
+
"""Drawdown now and how unusual it is for this series."""
|
|
83
|
+
s = series.dropna()
|
|
84
|
+
if len(s) < TRADING_DAYS:
|
|
85
|
+
return None
|
|
86
|
+
dd = _drawdown(s)
|
|
87
|
+
now = float(dd.iloc[-1])
|
|
88
|
+
deeper_than = float((dd > now).mean() * 100) # % of past days that were shallower
|
|
89
|
+
return {"below_high": now, "deeper_than_pct": deeper_than, "high": float(s.cummax().iloc[-1])}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def assess(nav: pd.Series, market: Optional[pd.Series] = None) -> dict:
|
|
93
|
+
from .sip_days import clean
|
|
94
|
+
|
|
95
|
+
nav = clean(nav)
|
|
96
|
+
nav = nav[nav.index >= nav.index[-1] - pd.DateOffset(years=YEARS)] if len(nav) else nav
|
|
97
|
+
if len(nav) < 2 * TRADING_DAYS:
|
|
98
|
+
return {"status": "short", "reason": "Needs at least 2 years of NAV history."}
|
|
99
|
+
|
|
100
|
+
stats = dip_stats(nav)
|
|
101
|
+
below_52w = float(nav.iloc[-1] / nav.iloc[-TRADING_DAYS:].max() - 1.0) # for the user's own % alerts
|
|
102
|
+
sma200 = float(nav.rolling(200).mean().iloc[-1])
|
|
103
|
+
price = float(nav.iloc[-1])
|
|
104
|
+
below_avg = price / sma200 - 1.0
|
|
105
|
+
change_10d = float(nav.iloc[-1] / nav.iloc[-11] - 1.0)
|
|
106
|
+
|
|
107
|
+
# What happened after past dips at least this deep (days with a full year after them).
|
|
108
|
+
fwd = (nav.shift(-TRADING_DAYS) / nav - 1.0).dropna()
|
|
109
|
+
dd = _drawdown(nav).reindex(fwd.index)
|
|
110
|
+
depth = min(stats["below_high"], MIN_DIP)
|
|
111
|
+
similar = fwd[dd <= depth]
|
|
112
|
+
if len(similar) < 20: # a record dip: compare with dips at least 3/4 as deep
|
|
113
|
+
depth = depth * 0.75
|
|
114
|
+
similar = fwd[dd <= depth]
|
|
115
|
+
typical = float(fwd.median())
|
|
116
|
+
after_dips = float(similar.median()) if len(similar) >= 20 else None
|
|
117
|
+
win_rate = float((similar > 0).mean() * 100) if len(similar) >= 20 else None
|
|
118
|
+
|
|
119
|
+
conditions = [
|
|
120
|
+
{"key": "dip", "label": "Unusual dip for this fund",
|
|
121
|
+
"pass": stats["below_high"] <= MIN_DIP and stats["deeper_than_pct"] >= DIP_PCTL,
|
|
122
|
+
"detail": f"{-stats['below_high'] * 100:.1f}% below its highest NAV, deeper than "
|
|
123
|
+
f"{stats['deeper_than_pct']:.0f}% of days in the last {YEARS} years"},
|
|
124
|
+
{"key": "trend", "label": "Below its 200-day average",
|
|
125
|
+
"pass": below_avg < 0, "detail": f"NAV {below_avg * 100:+.1f}% vs its 200-day average"},
|
|
126
|
+
{"key": "history", "label": "Past dips like this paid off",
|
|
127
|
+
"pass": after_dips is not None and after_dips > typical,
|
|
128
|
+
"detail": (f"1 year after dips of {-depth * 100:.0f}% or more: median {after_dips * 100:+.1f}% "
|
|
129
|
+
f"(positive {win_rate:.0f}% of the time) vs {typical * 100:+.1f}% from any day")
|
|
130
|
+
if after_dips is not None else "Not enough past dips this deep to compare"},
|
|
131
|
+
]
|
|
132
|
+
if market is not None:
|
|
133
|
+
m = dip_stats(market.dropna())
|
|
134
|
+
if m:
|
|
135
|
+
conditions.append({
|
|
136
|
+
"key": "market", "label": "Market also in a dip",
|
|
137
|
+
"pass": m["below_high"] <= MIN_DIP and m["deeper_than_pct"] >= MARKET_PCTL,
|
|
138
|
+
"detail": f"Nifty 50 {-m['below_high'] * 100:.1f}% below its high, deeper than "
|
|
139
|
+
f"{m['deeper_than_pct']:.0f}% of days"})
|
|
140
|
+
met = sum(c["pass"] for c in conditions)
|
|
141
|
+
own_dip = conditions[0]["pass"]
|
|
142
|
+
# Strong needs the fund's own dip to be unusual, not just a falling market.
|
|
143
|
+
level = "strong" if met >= 3 and own_dip else "watch" if met >= 2 else "none"
|
|
144
|
+
if stats["below_high"] > MIN_DIP:
|
|
145
|
+
level = "none" # near the high: nothing to time
|
|
146
|
+
return {
|
|
147
|
+
"status": "ok",
|
|
148
|
+
"level": level,
|
|
149
|
+
"met": met,
|
|
150
|
+
"total": len(conditions),
|
|
151
|
+
"conditions": conditions,
|
|
152
|
+
"nav": price,
|
|
153
|
+
"below_high": stats["below_high"],
|
|
154
|
+
"below_52w_high": below_52w,
|
|
155
|
+
"deeper_than_pct": stats["deeper_than_pct"],
|
|
156
|
+
"below_200d": below_avg,
|
|
157
|
+
"change_10d": change_10d,
|
|
158
|
+
"turning": below_avg < 0 and change_10d > 0,
|
|
159
|
+
"rsi": _rsi(nav),
|
|
160
|
+
"after_dips_1y": after_dips,
|
|
161
|
+
"typical_1y": typical,
|
|
162
|
+
"win_rate": win_rate,
|
|
163
|
+
"recovery_days": _recovery_days(nav, depth),
|
|
164
|
+
"as_of": str(nav.index[-1].date()),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def nifty_history() -> Optional[pd.Series]:
|
|
169
|
+
with _lock:
|
|
170
|
+
hit = _cache.get("^NSEI")
|
|
171
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
172
|
+
return hit[1].get("series")
|
|
173
|
+
try:
|
|
174
|
+
import yfinance as yf
|
|
175
|
+
|
|
176
|
+
h = yf.Ticker("^NSEI").history(period=f"{YEARS + 1}y")
|
|
177
|
+
s = h["Close"].dropna()
|
|
178
|
+
s.index = pd.DatetimeIndex(s.index).tz_localize(None).normalize()
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
logger.info("Nifty history unavailable: %s", exc)
|
|
181
|
+
return None
|
|
182
|
+
with _lock:
|
|
183
|
+
_cache["^NSEI"] = (time.time(), {"series": s})
|
|
184
|
+
return s
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def assess_fund(code: str, category: str = "", name: str = "") -> dict:
|
|
188
|
+
"""assess() for one AMFI scheme, cached for a few hours."""
|
|
189
|
+
from .mf_insights import asset_class
|
|
190
|
+
from .sip_days import mf_nav
|
|
191
|
+
|
|
192
|
+
if asset_class(category, name) not in CHECKED_CLASSES:
|
|
193
|
+
return {"status": "skipped", "reason": "Debt and liquid funds barely dip, so there is nothing to time."}
|
|
194
|
+
with _lock:
|
|
195
|
+
hit = _cache.get(code)
|
|
196
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
197
|
+
return hit[1]
|
|
198
|
+
nav = mf_nav(code)
|
|
199
|
+
out = assess(nav, nifty_history()) if nav is not None and len(nav) else {"status": "short", "reason": "No NAV history."}
|
|
200
|
+
with _lock:
|
|
201
|
+
_cache[code] = (time.time(), out)
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _xirr_pct(flows) -> Optional[float]:
|
|
206
|
+
from .sip import xirr
|
|
207
|
+
|
|
208
|
+
flows = sorted(flows)
|
|
209
|
+
if len(flows) < 2:
|
|
210
|
+
return None
|
|
211
|
+
r = xirr(flows)
|
|
212
|
+
return r * 100 if r is not None else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def portfolio_funds(summary: dict) -> dict:
|
|
216
|
+
"""Mutual fund view of a portfolio summary: per fund invested, value,
|
|
217
|
+
gain, personal XIRR, category, best SIP day and the lumpsum window check."""
|
|
218
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
219
|
+
from datetime import date, datetime
|
|
220
|
+
|
|
221
|
+
from . import sip_days
|
|
222
|
+
from .mf_holdings import MARKET, latest
|
|
223
|
+
from .mf_insights import asset_class, sub_category
|
|
224
|
+
|
|
225
|
+
funds = [h for h in summary.get("holdings", {}).values()
|
|
226
|
+
if (h.get("market") or "").lower() == MARKET and h.get("quantity")]
|
|
227
|
+
txs = summary.get("transactions", [])
|
|
228
|
+
best_days = sip_days.load("india")
|
|
229
|
+
today = date.today()
|
|
230
|
+
|
|
231
|
+
def row(h: dict) -> dict:
|
|
232
|
+
code = str(h["symbol"])
|
|
233
|
+
info = latest(code) or {}
|
|
234
|
+
name, category = info.get("name") or h.get("name") or code, info.get("category") or ""
|
|
235
|
+
flows, all_flows = [], []
|
|
236
|
+
for t in txs:
|
|
237
|
+
if str(t.get("symbol")) != code or not t.get("date"):
|
|
238
|
+
continue
|
|
239
|
+
d = datetime.fromisoformat(t["date"]).date()
|
|
240
|
+
amount = float(t["quantity"]) * float(t["price"])
|
|
241
|
+
kind = t.get("transaction_type")
|
|
242
|
+
if kind == "buy":
|
|
243
|
+
flows.append((d, -(amount + float(t.get("fees") or 0))))
|
|
244
|
+
elif kind in ("sell", "dividend"):
|
|
245
|
+
flows.append((d, amount - float(t.get("fees") or 0)))
|
|
246
|
+
value = float(h.get("market_value") or 0)
|
|
247
|
+
if flows:
|
|
248
|
+
all_flows = flows + [(today, value)]
|
|
249
|
+
best = best_days.get(code) or {}
|
|
250
|
+
return {
|
|
251
|
+
"code": code, "name": name, "amc": info.get("amc"), "category": sub_category(category),
|
|
252
|
+
"asset_class": asset_class(category, name),
|
|
253
|
+
"units": h.get("quantity"), "nav": info.get("nav") or h.get("current_price"), "nav_date": info.get("nav_date"),
|
|
254
|
+
"invested": h.get("cost_basis"), "value": value, "gain": h.get("unrealized_gain"),
|
|
255
|
+
"gain_pct": h.get("unrealized_gain_percent"),
|
|
256
|
+
"xirr_pct": _xirr_pct(all_flows) if all_flows and all_flows[0][0] < today else None,
|
|
257
|
+
"best_sip_day": best.get("best_day"), "sip_edge": best.get("edge"),
|
|
258
|
+
"lumpsum": assess_fund(code, category, name),
|
|
259
|
+
"_flows": flows,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
263
|
+
rows = list(pool.map(row, funds))
|
|
264
|
+
every = [f for r in rows for f in r.pop("_flows")]
|
|
265
|
+
invested = sum(float(r["invested"] or 0) for r in rows)
|
|
266
|
+
value = sum(r["value"] for r in rows)
|
|
267
|
+
rows.sort(key=lambda r: -r["value"])
|
|
268
|
+
from ..core.compliance import mode
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
"funds": rows,
|
|
272
|
+
"total": {"invested": invested, "value": value, "gain": value - invested,
|
|
273
|
+
"xirr_pct": _xirr_pct(every + [(today, value)]) if every else None},
|
|
274
|
+
"compliance": mode(),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------------ dip alerts
|
|
279
|
+
|
|
280
|
+
LEVEL_RANK = {"none": 0, "watch": 1, "strong": 2}
|
|
281
|
+
REMIND_DAYS = 7
|
|
282
|
+
ALERT_EVERY = 6 * 3600
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _state_path():
|
|
286
|
+
|
|
287
|
+
p = app_home() / "mf_dip_alerts.json"
|
|
288
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
return p
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _load_state() -> dict:
|
|
293
|
+
import json
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
return json.loads(_state_path().read_text(encoding="utf-8"))
|
|
297
|
+
except (OSError, ValueError):
|
|
298
|
+
return {}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _save_state(state: dict) -> None:
|
|
302
|
+
import json
|
|
303
|
+
|
|
304
|
+
tmp = _state_path().with_suffix(".tmp")
|
|
305
|
+
tmp.write_text(json.dumps(state), encoding="utf-8")
|
|
306
|
+
tmp.replace(_state_path())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def held_funds(config) -> list[str]:
|
|
310
|
+
"""AMFI codes of mutual funds held in the current scope's portfolios."""
|
|
311
|
+
from ..storage import DatabaseManager, HoldingRecord, PortfolioRecord, get_db_url
|
|
312
|
+
|
|
313
|
+
with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
|
|
314
|
+
rows = (session.query(HoldingRecord.portfolio_id, HoldingRecord.symbol, HoldingRecord.market,
|
|
315
|
+
HoldingRecord.quantity)
|
|
316
|
+
.join(PortfolioRecord, HoldingRecord.portfolio_id == PortfolioRecord.id).all())
|
|
317
|
+
return sorted({str(sym) for _pid, sym, market, qty in rows if (market or "").lower() == "mf" and (qty or 0) > 0})
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def alert_text(name: str, a: dict) -> tuple[str, str]:
|
|
321
|
+
"""(title, message) for one fund's dip: facts, no "opportunity" (marketmyna/core/compliance.py)."""
|
|
322
|
+
below = -a["below_high"] * 100
|
|
323
|
+
lines = []
|
|
324
|
+
title = f"📉 Dip check {a['met']} of {a['total']}: {name}"
|
|
325
|
+
lines.append(title)
|
|
326
|
+
lines.append(f"{below:.1f}% below its highest NAV, deeper than {a['deeper_than_pct']:.0f}% of days in the last {YEARS} years.")
|
|
327
|
+
if a.get("after_dips_1y") is not None:
|
|
328
|
+
lines.append(f"1 year after past dips this deep: median {a['after_dips_1y'] * 100:+.1f}% "
|
|
329
|
+
f"(vs {a['typical_1y'] * 100:+.1f}% from any day).")
|
|
330
|
+
if a.get("turning"):
|
|
331
|
+
lines.append("The NAV has started rising again over the last 10 days.")
|
|
332
|
+
lines.append("Facts about past behaviour, not investment advice.")
|
|
333
|
+
return title, "\n".join(lines)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def check_dips(config, state: dict, user_key: str, dispatcher=None, today=None) -> list[str]:
|
|
337
|
+
"""Alerts for the current scope's funds that reached (or rose to) watch /
|
|
338
|
+
strong; updates `state`. Returns the codes alerted."""
|
|
339
|
+
from datetime import date
|
|
340
|
+
|
|
341
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
342
|
+
from .mf_holdings import latest
|
|
343
|
+
|
|
344
|
+
today = today or date.today()
|
|
345
|
+
mine = state.setdefault(user_key, {})
|
|
346
|
+
sent = []
|
|
347
|
+
for code in held_funds(config):
|
|
348
|
+
info = latest(code) or {}
|
|
349
|
+
a = assess_fund(code, info.get("category") or "", info.get("name") or "")
|
|
350
|
+
if a.get("status") != "ok":
|
|
351
|
+
continue
|
|
352
|
+
level, prev = a["level"], mine.get(code) or {}
|
|
353
|
+
if level == "none":
|
|
354
|
+
mine.pop(code, None) # reset, so the next dip alerts again
|
|
355
|
+
continue
|
|
356
|
+
rose = LEVEL_RANK[level] > LEVEL_RANK.get(prev.get("level", "none"), 0)
|
|
357
|
+
stale = prev.get("date") and (today - date.fromisoformat(prev["date"])).days >= REMIND_DAYS
|
|
358
|
+
if not (rose or (level == "strong" and stale)):
|
|
359
|
+
continue
|
|
360
|
+
title, message = alert_text(info.get("name") or code, a)
|
|
361
|
+
(dispatcher or NotificationDispatcher(config)).send_all(message, title=title, purpose="alert")
|
|
362
|
+
mine[code] = {"level": level, "date": today.isoformat()}
|
|
363
|
+
sent.append(code)
|
|
364
|
+
return sent
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def alert_loop(config, first_delay: int = 900, every: int = ALERT_EVERY) -> None:
|
|
368
|
+
"""Background thread: dip alerts for every user's mutual funds."""
|
|
369
|
+
from ..core import tenant
|
|
370
|
+
from .accounts import for_each_user
|
|
371
|
+
|
|
372
|
+
time.sleep(first_delay)
|
|
373
|
+
while True:
|
|
374
|
+
try:
|
|
375
|
+
state = _load_state()
|
|
376
|
+
|
|
377
|
+
def run():
|
|
378
|
+
uid = tenant.current_user_id() if tenant.enabled() else None
|
|
379
|
+
key = str(uid) if uid is not None else "owner"
|
|
380
|
+
check_dips(config, state, key)
|
|
381
|
+
check_levels(config, state, key)
|
|
382
|
+
|
|
383
|
+
for_each_user(config, run, "Fund dip alerts")
|
|
384
|
+
_save_state(state)
|
|
385
|
+
except Exception as exc: # never kill the thread
|
|
386
|
+
logger.warning("Fund dip alerts: %s", exc)
|
|
387
|
+
time.sleep(every)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ------------------------------------------------------------ AI review
|
|
391
|
+
|
|
392
|
+
def _review_prompt(name: str, category: str, a: dict) -> str:
|
|
393
|
+
lines = [f"Fund: {name} ({category}). NAV data as of {a.get('as_of')}."]
|
|
394
|
+
for c in a.get("conditions", []):
|
|
395
|
+
lines.append(f"- {c['label']}: {'yes' if c['pass'] else 'no'} ({c['detail']})")
|
|
396
|
+
lines.append(f"- NAV {'has started rising' if a.get('turning') else 'has not turned up'} over the last 10 days; "
|
|
397
|
+
f"RSI {a.get('rsi') and round(a['rsi'])}.")
|
|
398
|
+
if a.get("recovery_days"):
|
|
399
|
+
lines.append(f"- Past dips this deep took a median {round(a['recovery_days'] / 21)} months to regain the high.")
|
|
400
|
+
return "\n".join(lines)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def ai_review(code: str, config) -> Optional[dict]:
|
|
404
|
+
"""AI read of one fund's dip: an explanation without a recommendation.
|
|
405
|
+
None when no AI is available or the reply can't be parsed."""
|
|
406
|
+
from ..llm.backend_factory import create_generation_backend
|
|
407
|
+
from ..llm.json_reply import parse_object
|
|
408
|
+
from ..llm.protocol import GenerationConfig
|
|
409
|
+
from .mf_holdings import latest
|
|
410
|
+
|
|
411
|
+
info = latest(code) or {}
|
|
412
|
+
name, category = info.get("name") or code, info.get("category") or ""
|
|
413
|
+
a = assess_fund(code, category, name)
|
|
414
|
+
if a.get("status") != "ok":
|
|
415
|
+
return {"summary": a.get("reason") or "No dip check for this fund.", "reasons": [], "risk": None, "view": None}
|
|
416
|
+
system = (
|
|
417
|
+
"You explain mutual fund data to retail investors for an analytics tool that is not a registered "
|
|
418
|
+
"adviser. Using only the figures given, explain how unusual today's dip is for this fund, what "
|
|
419
|
+
"happened after similar past dips, and the main risks. Never recommend investing, adding money, "
|
|
420
|
+
"a lumpsum, buying, holding or selling, and give no timing advice. Reply with ONLY a JSON object: "
|
|
421
|
+
'{"summary": "2-3 sentences", "reasons": ["2-3 short factual points"], "risk": "one risk to keep in mind"}'
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
result = create_generation_backend(config).generate(
|
|
425
|
+
_review_prompt(name, category, a), GenerationConfig(max_tokens=500, timeout=90), system_prompt=system)
|
|
426
|
+
except Exception as exc:
|
|
427
|
+
logger.warning("Fund AI review unavailable: %s", exc)
|
|
428
|
+
raise
|
|
429
|
+
data = parse_object(result.text)
|
|
430
|
+
if not data or not data.get("summary"):
|
|
431
|
+
return None
|
|
432
|
+
return {"view": None, # no timing view (marketmyna/core/compliance.py)
|
|
433
|
+
"summary": str(data["summary"]), "reasons": [str(r) for r in (data.get("reasons") or [])][:4],
|
|
434
|
+
"risk": data.get("risk")}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ------------------------------------------------- the user's own % alerts
|
|
438
|
+
|
|
439
|
+
LEVEL_CHOICES = [3.0, 5.0, 7.5, 10.0, 15.0, 20.0] # % below the 52-week high
|
|
440
|
+
RESET_NEAR_HIGH = -0.015 # back within 1.5% of the high: the ladder re-arms
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _clean_levels(values) -> list[float]:
|
|
444
|
+
out = sorted({float(v) for v in (values or [])})
|
|
445
|
+
bad = [v for v in out if v not in LEVEL_CHOICES]
|
|
446
|
+
if bad:
|
|
447
|
+
raise ValueError(f"Choose levels from {', '.join(f'{c:g}%' for c in LEVEL_CHOICES)}.")
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _portfolio(session, name: str):
|
|
452
|
+
from ..storage import PortfolioRecord
|
|
453
|
+
|
|
454
|
+
p = session.query(PortfolioRecord).filter_by(name=name).first()
|
|
455
|
+
if p is None:
|
|
456
|
+
raise LookupError(f"Portfolio '{name}' not found")
|
|
457
|
+
return p
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def get_rules(config, name: str) -> dict:
|
|
461
|
+
from ..storage import DatabaseManager, FundDipRuleRecord, get_db_url
|
|
462
|
+
|
|
463
|
+
with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
|
|
464
|
+
p = _portfolio(session, name)
|
|
465
|
+
rows = session.query(FundDipRuleRecord).filter_by(portfolio_id=p.id).all()
|
|
466
|
+
default = next((r.levels for r in rows if r.fund_code is None), None) or []
|
|
467
|
+
return {"levels": default, "overrides": {r.fund_code: r.levels or [] for r in rows if r.fund_code},
|
|
468
|
+
"choices": LEVEL_CHOICES}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def set_rules(config, name: str, levels, overrides: Optional[dict] = None) -> dict:
|
|
472
|
+
"""levels: the portfolio default; overrides: fund code -> its own levels
|
|
473
|
+
([] = no % alerts for that fund). A code left out uses the default."""
|
|
474
|
+
from ..storage import DatabaseManager, FundDipRuleRecord, get_db_url
|
|
475
|
+
|
|
476
|
+
wanted = {None: _clean_levels(levels)}
|
|
477
|
+
for code, lv in (overrides or {}).items():
|
|
478
|
+
wanted[str(code)] = _clean_levels(lv)
|
|
479
|
+
with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
|
|
480
|
+
p = _portfolio(session, name)
|
|
481
|
+
existing = {r.fund_code: r for r in session.query(FundDipRuleRecord).filter_by(portfolio_id=p.id)}
|
|
482
|
+
for code, row in existing.items():
|
|
483
|
+
if code not in wanted:
|
|
484
|
+
session.delete(row)
|
|
485
|
+
for code, lv in wanted.items():
|
|
486
|
+
row = existing.get(code) or FundDipRuleRecord(portfolio_id=p.id, fund_code=code)
|
|
487
|
+
row.levels = lv
|
|
488
|
+
session.add(row)
|
|
489
|
+
return get_rules(config, name)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def held_with_levels(config) -> list[dict]:
|
|
493
|
+
"""[{portfolio_id, portfolio, code, levels}] for every mutual fund held in
|
|
494
|
+
the current scope that has % alert levels."""
|
|
495
|
+
from ..storage import (
|
|
496
|
+
DatabaseManager,
|
|
497
|
+
FundDipRuleRecord,
|
|
498
|
+
HoldingRecord,
|
|
499
|
+
PortfolioRecord,
|
|
500
|
+
get_db_url,
|
|
501
|
+
)
|
|
502
|
+
with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
|
|
503
|
+
holdings = (session.query(PortfolioRecord.id, PortfolioRecord.name, HoldingRecord.symbol,
|
|
504
|
+
HoldingRecord.market, HoldingRecord.quantity)
|
|
505
|
+
.join(HoldingRecord, HoldingRecord.portfolio_id == PortfolioRecord.id).all())
|
|
506
|
+
rules = {(r.portfolio_id, r.fund_code): r.levels or [] for r in session.query(FundDipRuleRecord)}
|
|
507
|
+
out = []
|
|
508
|
+
for pid, pname, code, market, qty in holdings:
|
|
509
|
+
if (market or "").lower() != "mf" or (qty or 0) <= 0:
|
|
510
|
+
continue
|
|
511
|
+
levels = rules.get((pid, str(code)), rules.get((pid, None), []))
|
|
512
|
+
if levels:
|
|
513
|
+
out.append({"portfolio_id": pid, "portfolio": pname, "code": str(code), "levels": levels})
|
|
514
|
+
return out
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def level_text(name: str, a: dict, level: float) -> tuple[str, str]:
|
|
518
|
+
below = -a["below_52w_high"] * 100
|
|
519
|
+
title = f"📉 {name}: {below:.1f}% below its 52-week high"
|
|
520
|
+
lines = [title, f"Your {level:g}% dip alert was reached."]
|
|
521
|
+
if a.get("level") in ("watch", "strong"):
|
|
522
|
+
lines.append(f"Dip check: {a['met']} of {a['total']} conditions met.")
|
|
523
|
+
if a.get("after_dips_1y") is not None:
|
|
524
|
+
lines.append(f"1 year after past dips like this: median {a['after_dips_1y'] * 100:+.1f}%.")
|
|
525
|
+
lines.append("Your own alert rule; facts, not investment advice.")
|
|
526
|
+
return title, "\n".join(lines)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def check_levels(config, state: dict, user_key: str, dispatcher=None) -> list[tuple[str, float]]:
|
|
530
|
+
"""The user's own % alerts: one alert per level as a fund falls through it
|
|
531
|
+
(once per dip), re-armed when it gets back near its 52-week high."""
|
|
532
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
533
|
+
from .mf_holdings import latest
|
|
534
|
+
|
|
535
|
+
ladder = state.setdefault(user_key, {}).setdefault("_ladder", {})
|
|
536
|
+
sent = []
|
|
537
|
+
for h in held_with_levels(config):
|
|
538
|
+
info = latest(h["code"]) or {}
|
|
539
|
+
a = assess_fund(h["code"], info.get("category") or "", info.get("name") or "")
|
|
540
|
+
dip = a.get("below_52w_high")
|
|
541
|
+
if a.get("status") != "ok" or dip is None:
|
|
542
|
+
continue
|
|
543
|
+
key = f"{h['portfolio_id']}:{h['code']}"
|
|
544
|
+
fired = ladder.get(key, [])
|
|
545
|
+
if dip > RESET_NEAR_HIGH:
|
|
546
|
+
ladder.pop(key, None)
|
|
547
|
+
continue
|
|
548
|
+
crossed = [lv for lv in h["levels"] if dip <= -lv / 100 and lv not in fired]
|
|
549
|
+
if not crossed:
|
|
550
|
+
continue
|
|
551
|
+
deepest = max(crossed)
|
|
552
|
+
title, message = level_text(info.get("name") or h["code"], a, deepest)
|
|
553
|
+
(dispatcher or NotificationDispatcher(config)).send_all(message, title=title, purpose="alert")
|
|
554
|
+
ladder[key] = sorted(set(fired) | set(crossed))
|
|
555
|
+
sent.append((h["code"], deepest))
|
|
556
|
+
return sent
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def send_test_alert(config, name: str, dispatcher=None) -> dict:
|
|
560
|
+
"""A sample dip alert for the first fund in the portfolio, to see how it
|
|
561
|
+
looks and that it arrives. Says clearly that it is a test."""
|
|
562
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
563
|
+
from .mf_holdings import latest
|
|
564
|
+
|
|
565
|
+
funds = [h for h in held_with_levels(config) if h["portfolio"] == name] or \
|
|
566
|
+
[{"code": c, "levels": [5.0]} for c in _codes_in(config, name)]
|
|
567
|
+
if not funds:
|
|
568
|
+
raise LookupError("Add a mutual fund to this portfolio first.")
|
|
569
|
+
code = funds[0]["code"]
|
|
570
|
+
info = latest(code) or {}
|
|
571
|
+
a = assess_fund(code, info.get("category") or "", info.get("name") or "")
|
|
572
|
+
if a.get("status") != "ok":
|
|
573
|
+
a = {"below_52w_high": -0.05, "level": "none", "met": 0, "total": 4}
|
|
574
|
+
level = min(funds[0]["levels"] or [5.0])
|
|
575
|
+
title, message = level_text(info.get("name") or code, a, level)
|
|
576
|
+
delivery = (dispatcher or NotificationDispatcher(config)).send_all(
|
|
577
|
+
"TEST - this is how your dip alert will look.\n" + message, title="Test: " + title, purpose="alert")
|
|
578
|
+
return {"sent": delivery, "fund": info.get("name") or code}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _codes_in(config, name: str) -> list[str]:
|
|
582
|
+
from ..storage import DatabaseManager, HoldingRecord, get_db_url
|
|
583
|
+
|
|
584
|
+
with DatabaseManager.instance(get_db_url(config=config)).get_session() as session:
|
|
585
|
+
p = _portfolio(session, name)
|
|
586
|
+
rows = session.query(HoldingRecord).filter_by(portfolio_id=p.id).all()
|
|
587
|
+
return [str(r.symbol) for r in rows if (r.market or "").lower() == "mf" and (r.quantity or 0) > 0]
|