marketmyna 0.2.0__py3-none-any.whl

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