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,500 @@
1
+ """Market scanner: run trader scans over a universe of stocks.
2
+
3
+ Daily bars (with today's forming bar last) come from one batched Yahoo
4
+ download per universe, so 100-500 stocks take seconds. Intraday scans
5
+ (opening-range breakout, VWAP cross) add a batched 5-minute download.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Callable, Iterable, Optional
15
+
16
+ import pandas as pd
17
+
18
+ from ..core.numbers import finite as _num
19
+ from . import indicators as ind
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # id -> (label, group, needs intraday, default parameter, parameter label)
24
+ SCANS: dict[str, tuple[str, str, bool, Optional[float], Optional[str]]] = {
25
+ "gap_up": ("Gap up", "Gaps & moves", False, 2.0, "min gap %"),
26
+ "gap_down": ("Gap down", "Gaps & moves", False, 2.0, "min gap %"),
27
+ "big_mover": ("Big mover", "Gaps & moves", False, 3.0, "min move %"),
28
+ "volume_surge": ("Volume surge", "Gaps & moves", False, 2.0, "x 20-day avg"),
29
+ "high_52w": ("New 52-week high", "Breakouts", False, None, None),
30
+ "near_high_52w": ("Near 52-week high", "Breakouts", False, 2.0, "within %"),
31
+ "low_52w": ("New 52-week low", "Breakouts", False, None, None),
32
+ "breakout_20d": ("20-day breakout", "Breakouts", False, 20.0, "days"),
33
+ "breakdown_20d": ("20-day breakdown", "Breakouts", False, 20.0, "days"),
34
+ "rsi_overbought": ("RSI overbought", "Momentum", False, 70.0, "RSI ≥"),
35
+ "rsi_oversold": ("RSI oversold", "Momentum", False, 30.0, "RSI ≤"),
36
+ "ema_cross_up": ("EMA 9/21 bullish cross", "Momentum", False, None, None),
37
+ "ema_cross_down": ("EMA 9/21 bearish cross", "Momentum", False, None, None),
38
+ "supertrend_buy": ("Supertrend turned up", "Momentum", False, None, None),
39
+ "supertrend_sell": ("Supertrend turned down", "Momentum", False, None, None),
40
+ "uptrend": ("Uptrend (price > SMA50 > SMA200)", "Momentum", False, None, None),
41
+ "macd_cross_up": ("MACD bullish cross", "Momentum", False, None, None),
42
+ "macd_cross_down": ("MACD bearish cross", "Momentum", False, None, None),
43
+ "stoch_oversold": ("Stochastic oversold", "Momentum", False, 20.0, "%K ≤"),
44
+ "stoch_overbought": ("Stochastic overbought", "Momentum", False, 80.0, "%K ≥"),
45
+ "mfi_oversold": ("Money flow oversold", "Momentum", False, 20.0, "MFI ≤"),
46
+ "mfi_overbought": ("Money flow overbought", "Momentum", False, 80.0, "MFI ≥"),
47
+ "cci_oversold": ("CCI oversold", "Momentum", False, -100.0, "CCI ≤"),
48
+ "cci_overbought": ("CCI overbought", "Momentum", False, 100.0, "CCI ≥"),
49
+ "adx_strong_up": ("Strong uptrend (ADX)", "Momentum", False, 25.0, "ADX ≥"),
50
+ "adx_strong_down": ("Strong downtrend (ADX)", "Momentum", False, 25.0, "ADX ≥"),
51
+ "ichimoku_above_cloud": ("Above Ichimoku cloud", "Momentum", False, None, None),
52
+ "ichimoku_tk_cross_up": ("Ichimoku Tenkan/Kijun bullish cross", "Momentum", False, None, None),
53
+ "bb_squeeze": ("Bollinger squeeze (inside Keltner)", "Volatility", False, None, None),
54
+ "bb_squeeze_fire_up": ("Squeeze released upward", "Volatility", False, None, None),
55
+ "bb_squeeze_fire_down": ("Squeeze released downward", "Volatility", False, None, None),
56
+ "bb_upper_break": ("Close above upper Bollinger band", "Volatility", False, None, None),
57
+ "bb_lower_break": ("Close below lower Bollinger band", "Volatility", False, None, None),
58
+ "rsi_bull_div": ("RSI bullish divergence", "Divergences", False, None, None),
59
+ "rsi_bear_div": ("RSI bearish divergence", "Divergences", False, None, None),
60
+ "macd_bull_div": ("MACD bullish divergence", "Divergences", False, None, None),
61
+ "macd_bear_div": ("MACD bearish divergence", "Divergences", False, None, None),
62
+ "near_support": ("Near 20-day support", "Patterns", False, 2.0, "within %"),
63
+ "open_low": ("Open = low (bullish)", "Patterns", False, 0.1, "tolerance %"),
64
+ "open_high": ("Open = high (bearish)", "Patterns", False, 0.1, "tolerance %"),
65
+ "inside_day": ("Inside day", "Patterns", False, None, None),
66
+ "nr7": ("NR7 (narrowest range in 7)", "Patterns", False, None, None),
67
+ **{key: (label, "Candlesticks", False, None, None) for key, (label, _) in ind.CANDLE_PATTERNS.items()},
68
+ "orb_up": ("Opening-range breakout up", "Intraday", True, 15.0, "range minutes"),
69
+ "orb_down": ("Opening-range breakdown", "Intraday", True, 15.0, "range minutes"),
70
+ "vwap_cross_up": ("Crossed above VWAP", "Intraday", True, None, None),
71
+ "vwap_cross_down": ("Crossed below VWAP", "Intraday", True, None, None),
72
+ }
73
+
74
+ CACHE_SECONDS = 180
75
+ CHUNK = 150
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Target:
80
+ symbol: str
81
+ market: str
82
+
83
+
84
+ # ------------------------------------------------------------------ data
85
+
86
+
87
+ def _yahoo(symbol: str, market: str) -> str:
88
+ from ..services.analysis_run import yahoo_symbol
89
+
90
+ return yahoo_symbol(symbol, market)
91
+
92
+
93
+ def _yf_download(tickers: list[str], interval: str, period: str) -> dict[str, pd.DataFrame]:
94
+ import yfinance as yf
95
+
96
+ from ..utils.yf_lock import YF_DOWNLOAD_LOCK
97
+
98
+ out: dict[str, pd.DataFrame] = {}
99
+ for i in range(0, len(tickers), CHUNK):
100
+ chunk = tickers[i:i + CHUNK]
101
+ with YF_DOWNLOAD_LOCK:
102
+ df = yf.download(chunk, period=period, interval=interval, group_by="ticker", threads=True,
103
+ progress=False, auto_adjust=False, timeout=20)
104
+ if df is None or df.empty:
105
+ continue
106
+ for t in chunk:
107
+ try:
108
+ sub = df[t] if isinstance(df.columns, pd.MultiIndex) else df
109
+ except KeyError:
110
+ continue
111
+ sub = sub.rename(columns=str.lower).dropna(subset=["close"])
112
+ if not sub.empty:
113
+ out[t] = sub
114
+ return out
115
+
116
+
117
+ class BarCache:
118
+ """Short-lived cache so repeated scans and saved scans share downloads."""
119
+
120
+ def __init__(self, fetch: Callable[[list[str], str, str], dict[str, pd.DataFrame]] = _yf_download):
121
+ self._fetch = fetch
122
+ self._data: dict[tuple[str, str], tuple[float, pd.DataFrame]] = {}
123
+ self._lock = threading.Lock()
124
+
125
+ def get(self, tickers: Iterable[str], interval: str, period: str) -> dict[str, pd.DataFrame]:
126
+ now = time.time()
127
+ tickers = list(dict.fromkeys(tickers))
128
+ with self._lock:
129
+ fresh = {t: self._data[(t, interval)][1] for t in tickers
130
+ if (t, interval) in self._data and now - self._data[(t, interval)][0] < CACHE_SECONDS}
131
+ missing = [t for t in tickers if t not in fresh]
132
+ if missing:
133
+ got = self._fetch(missing, interval, period)
134
+ with self._lock:
135
+ for t, df in got.items():
136
+ self._data[(t, interval)] = (now, df)
137
+ fresh.update(got)
138
+ return fresh
139
+
140
+
141
+ _cache = BarCache()
142
+
143
+
144
+ # ------------------------------------------------------------------ scans
145
+
146
+
147
+ def evaluate(daily: pd.DataFrame, intraday: Optional[pd.DataFrame], scans: list[str],
148
+ params: dict[str, float]) -> tuple[dict, dict[str, str]]:
149
+ """(metrics, {scan id: why it matched}) for one stock."""
150
+ d = daily.dropna(subset=["close"])
151
+ if len(d) < 3:
152
+ return {}, {}
153
+ today, prev = d.iloc[-1], d.iloc[-2]
154
+ past = d.iloc[:-1]
155
+ close, open_, high, low = float(today["close"]), float(today["open"]), float(today["high"]), float(today["low"])
156
+ prev_close = float(prev["close"])
157
+ change = (close / prev_close - 1) * 100 if prev_close else 0.0
158
+ gap = (open_ / prev_close - 1) * 100 if prev_close else 0.0
159
+ vol = _num(today.get("volume")) or 0.0
160
+ avg_vol = float(past["volume"].tail(20).mean()) if "volume" in past else 0.0
161
+ vol_ratio = vol / avg_vol if avg_vol else None
162
+ rsi_series = ind.rsi(d["close"].astype(float))
163
+ rsi = _num(rsi_series.iloc[-1])
164
+ p = lambda k: params.get(k, SCANS[k][3]) # noqa: E731
165
+
166
+ metrics = {"price": close, "change_percent": change, "gap_percent": gap, "volume_ratio": vol_ratio, "rsi": rsi}
167
+ hits: dict[str, str] = {}
168
+ wanted = set(scans)
169
+
170
+ def hit(key: str, text: str) -> None:
171
+ if key in wanted:
172
+ hits[key] = text
173
+
174
+ if gap >= p("gap_up"):
175
+ hit("gap_up", f"gapped up {gap:+.1f}%")
176
+ if gap <= -p("gap_down"):
177
+ hit("gap_down", f"gapped down {gap:+.1f}%")
178
+ if abs(change) >= p("big_mover"):
179
+ hit("big_mover", f"moved {change:+.1f}%")
180
+ if vol_ratio is not None and vol_ratio >= p("volume_surge"):
181
+ hit("volume_surge", f"volume {vol_ratio:.1f}x average")
182
+
183
+ year = past.tail(252)
184
+ if len(year) >= 60:
185
+ hi52, lo52 = float(year["high"].max()), float(year["low"].min())
186
+ metrics["high_52w"], metrics["low_52w"] = hi52, lo52
187
+ if high > hi52:
188
+ hit("high_52w", f"new 52-week high {high:,.2f}")
189
+ if close >= hi52 * (1 - p("near_high_52w") / 100):
190
+ hit("near_high_52w", f"{(close / hi52 - 1) * 100:+.1f}% from 52-week high")
191
+ if low < lo52:
192
+ hit("low_52w", f"new 52-week low {low:,.2f}")
193
+
194
+ for key, is_up in (("breakout_20d", True), ("breakdown_20d", False)):
195
+ if key not in wanted:
196
+ continue
197
+ n = int(p(key))
198
+ win = past.tail(n)
199
+ if len(win) == n:
200
+ level = float(win["high"].max()) if is_up else float(win["low"].min())
201
+ if (close > level) if is_up else (close < level):
202
+ hit(key, f"{'above' if is_up else 'below'} {n}-day {'high' if is_up else 'low'} {level:,.2f}")
203
+
204
+ if rsi is not None:
205
+ if rsi >= p("rsi_overbought"):
206
+ hit("rsi_overbought", f"RSI {rsi:.0f}")
207
+ if rsi <= p("rsi_oversold"):
208
+ hit("rsi_oversold", f"RSI {rsi:.0f}")
209
+
210
+ if len(d) >= 25 and wanted & {"ema_cross_up", "ema_cross_down"}:
211
+ fast, slow = ind.ema(d["close"], 9), ind.ema(d["close"], 21)
212
+ now_diff, before = fast.iloc[-1] - slow.iloc[-1], fast.iloc[-2] - slow.iloc[-2]
213
+ if now_diff > 0 >= before:
214
+ hit("ema_cross_up", "EMA 9 crossed above EMA 21")
215
+ if now_diff < 0 <= before:
216
+ hit("ema_cross_down", "EMA 9 crossed below EMA 21")
217
+
218
+ if len(d) >= 15 and wanted & {"supertrend_buy", "supertrend_sell"}:
219
+ _, direction = ind.supertrend(d)
220
+ if direction.iloc[-1] == 1 and direction.iloc[-2] == -1:
221
+ hit("supertrend_buy", "Supertrend flipped up")
222
+ if direction.iloc[-1] == -1 and direction.iloc[-2] == 1:
223
+ hit("supertrend_sell", "Supertrend flipped down")
224
+
225
+ if len(d) >= 200:
226
+ sma50, sma200 = float(d["close"].tail(50).mean()), float(d["close"].tail(200).mean())
227
+ if close > sma50 > sma200:
228
+ hit("uptrend", "price > SMA50 > SMA200")
229
+
230
+ low20 = float(past["low"].tail(20).min()) if len(past) >= 20 else None
231
+ if low20 and low20 <= close <= low20 * (1 + p("near_support") / 100):
232
+ hit("near_support", f"{(close / low20 - 1) * 100:.1f}% above 20-day low {low20:,.2f}")
233
+
234
+ _extended_scans(d, wanted, p, hit)
235
+
236
+ tol = lambda k: open_ * p(k) / 100 # noqa: E731
237
+ if abs(open_ - low) <= tol("open_low") and close > open_:
238
+ hit("open_low", "opened at the day's low")
239
+ if abs(open_ - high) <= tol("open_high") and close < open_:
240
+ hit("open_high", "opened at the day's high")
241
+ if high < float(prev["high"]) and low > float(prev["low"]):
242
+ hit("inside_day", "inside yesterday's range")
243
+ if len(d) >= 7:
244
+ ranges = (d["high"] - d["low"]).tail(7)
245
+ if float(ranges.iloc[-1]) <= float(ranges.min()):
246
+ hit("nr7", "narrowest range of 7 days")
247
+
248
+ if intraday is not None and len(intraday) >= 3:
249
+ bars = intraday.dropna(subset=["close"])
250
+ days = pd.DatetimeIndex(bars.index).date
251
+ session = bars[days == days[-1]]
252
+ if len(session) >= 2:
253
+ last_close, prev_bar_close = float(session["close"].iloc[-1]), float(session["close"].iloc[-2])
254
+ rng = ind.opening_range(session, int(p("orb_up") if "orb_up" in wanted else p("orb_down")))
255
+ if rng:
256
+ metrics["or_high"], metrics["or_low"] = rng["high"], rng["low"]
257
+ if last_close > rng["high"]:
258
+ hit("orb_up", f"above opening range {rng['high']:,.2f}")
259
+ if last_close < rng["low"]:
260
+ hit("orb_down", f"below opening range {rng['low']:,.2f}")
261
+ if session["volume"].sum() > 0:
262
+ vw = ind.vwap(session)
263
+ v_now, v_before = float(vw.iloc[-1]), float(vw.iloc[-2])
264
+ metrics["vwap"] = v_now
265
+ if last_close > v_now and prev_bar_close <= v_before:
266
+ hit("vwap_cross_up", f"crossed above VWAP {v_now:,.2f}")
267
+ if last_close < v_now and prev_bar_close >= v_before:
268
+ hit("vwap_cross_down", f"crossed below VWAP {v_now:,.2f}")
269
+ return metrics, hits
270
+
271
+
272
+ def _crossed(a: pd.Series, b: pd.Series) -> int:
273
+ """1 if `a` crossed above `b` on the last bar, -1 if below, else 0."""
274
+ now, before = a.iloc[-1] - b.iloc[-1], a.iloc[-2] - b.iloc[-2]
275
+ if pd.isna(now) or pd.isna(before):
276
+ return 0
277
+ return 1 if now > 0 >= before else -1 if now < 0 <= before else 0
278
+
279
+
280
+ def _extended_scans(d: pd.DataFrame, wanted: set, p: Callable[[str], float], hit: Callable[[str, str], None]) -> None:
281
+ """Oscillator, channel, Ichimoku, candlestick and divergence scans.
282
+ Each block only runs when one of its scans is requested."""
283
+ close = d["close"].astype(float)
284
+
285
+ macd_df = None
286
+ if wanted & {"macd_cross_up", "macd_cross_down", "macd_bull_div", "macd_bear_div"} and len(d) >= 35:
287
+ macd_df = ind.macd(close)
288
+ cross = _crossed(macd_df["macd"], macd_df["signal"])
289
+ if cross == 1:
290
+ hit("macd_cross_up", "MACD crossed above signal")
291
+ if cross == -1:
292
+ hit("macd_cross_down", "MACD crossed below signal")
293
+
294
+ if wanted & {"stoch_oversold", "stoch_overbought"} and len(d) >= 17:
295
+ k = _num(ind.stochastic(d)["k"].iloc[-1])
296
+ if k is not None and k <= p("stoch_oversold"):
297
+ hit("stoch_oversold", f"%K {k:.0f}")
298
+ if k is not None and k >= p("stoch_overbought"):
299
+ hit("stoch_overbought", f"%K {k:.0f}")
300
+
301
+ if wanted & {"mfi_oversold", "mfi_overbought"} and len(d) >= 15:
302
+ m = _num(ind.mfi(d).iloc[-1])
303
+ if m is not None and m <= p("mfi_oversold"):
304
+ hit("mfi_oversold", f"MFI {m:.0f}")
305
+ if m is not None and m >= p("mfi_overbought"):
306
+ hit("mfi_overbought", f"MFI {m:.0f}")
307
+
308
+ if wanted & {"cci_oversold", "cci_overbought"} and len(d) >= 20:
309
+ c = _num(ind.cci(d).iloc[-1])
310
+ if c is not None and c <= p("cci_oversold"):
311
+ hit("cci_oversold", f"CCI {c:.0f}")
312
+ if c is not None and c >= p("cci_overbought"):
313
+ hit("cci_overbought", f"CCI {c:.0f}")
314
+
315
+ if wanted & {"adx_strong_up", "adx_strong_down"} and len(d) >= 30:
316
+ row = ind.adx(d).iloc[-1]
317
+ a = _num(row["adx"])
318
+ if a is not None and row["plus_di"] > row["minus_di"] and a >= p("adx_strong_up"):
319
+ hit("adx_strong_up", f"ADX {a:.0f}, +DI above -DI")
320
+ if a is not None and row["minus_di"] > row["plus_di"] and a >= p("adx_strong_down"):
321
+ hit("adx_strong_down", f"ADX {a:.0f}, -DI above +DI")
322
+
323
+ if wanted & {"ichimoku_above_cloud", "ichimoku_tk_cross_up"} and len(d) >= 80:
324
+ ich = ind.ichimoku(d)
325
+ top = max(float(ich["span_a"].iloc[-1]), float(ich["span_b"].iloc[-1]))
326
+ if float(close.iloc[-1]) > top:
327
+ hit("ichimoku_above_cloud", f"above cloud top {top:,.2f}")
328
+ if _crossed(ich["tenkan"], ich["kijun"]) == 1:
329
+ hit("ichimoku_tk_cross_up", "Tenkan crossed above Kijun")
330
+
331
+ if wanted & {"bb_squeeze", "bb_squeeze_fire_up", "bb_squeeze_fire_down", "bb_upper_break", "bb_lower_break"} and len(d) >= 25:
332
+ bb = ind.bollinger(close)
333
+ sq = ind.squeeze(d)
334
+ last = float(close.iloc[-1])
335
+ if bool(sq.iloc[-1]):
336
+ hit("bb_squeeze", f"band width {float(bb['width'].iloc[-1]):.1f}%")
337
+ if bool(sq.iloc[-2]) and not bool(sq.iloc[-1]):
338
+ if last > float(bb["mid"].iloc[-1]):
339
+ hit("bb_squeeze_fire_up", "squeeze ended, close above mid band")
340
+ else:
341
+ hit("bb_squeeze_fire_down", "squeeze ended, close below mid band")
342
+ if last > float(bb["upper"].iloc[-1]):
343
+ hit("bb_upper_break", f"above upper band {float(bb['upper'].iloc[-1]):,.2f}")
344
+ if last < float(bb["lower"].iloc[-1]):
345
+ hit("bb_lower_break", f"below lower band {float(bb['lower'].iloc[-1]):,.2f}")
346
+
347
+ if wanted & set(ind.CANDLE_PATTERNS):
348
+ for key in ind.candle_patterns(d):
349
+ hit(key, ind.CANDLE_PATTERNS[key][0].lower())
350
+
351
+ if wanted & {"rsi_bull_div", "rsi_bear_div"} and len(d) >= 40:
352
+ kind = ind.divergence(d, ind.rsi(close))
353
+ if kind == "bullish":
354
+ hit("rsi_bull_div", "lower low in price, higher low in RSI")
355
+ if kind == "bearish":
356
+ hit("rsi_bear_div", "higher high in price, lower high in RSI")
357
+ if wanted & {"macd_bull_div", "macd_bear_div"} and macd_df is not None:
358
+ kind = ind.divergence(d, macd_df["macd"])
359
+ if kind == "bullish":
360
+ hit("macd_bull_div", "lower low in price, higher low in MACD")
361
+ if kind == "bearish":
362
+ hit("macd_bear_div", "higher high in price, lower high in MACD")
363
+
364
+
365
+ def run_scan(targets: list[Target], scans: list[str], params: Optional[dict] = None, match: str = "any",
366
+ cache: Optional[BarCache] = None, names: Optional[dict] = None) -> dict:
367
+ """Scan every target. `match` = "any" (at least one scan) or "all"."""
368
+ started = time.time()
369
+ scans = [s for s in scans if s in SCANS]
370
+ params = {k: float(v) for k, v in (params or {}).items() if k in SCANS and v is not None}
371
+ cache = cache or _cache
372
+ by_ticker = {_yahoo(t.symbol, t.market): t for t in targets}
373
+ daily = cache.get(by_ticker.keys(), "1d", "1y")
374
+ intraday: dict[str, pd.DataFrame] = {}
375
+ if any(SCANS[s][2] for s in scans):
376
+ intraday = cache.get(by_ticker.keys(), "5m", "1d")
377
+
378
+ rows, failed = [], []
379
+ for ticker, t in by_ticker.items():
380
+ df = daily.get(ticker)
381
+ if df is None or df.empty:
382
+ failed.append(t.symbol)
383
+ continue
384
+ try:
385
+ metrics, hits = evaluate(df, intraday.get(ticker), scans, params)
386
+ except Exception as exc:
387
+ logger.warning("Scan of %s failed: %s", t.symbol, exc)
388
+ failed.append(t.symbol)
389
+ continue
390
+ ok = bool(hits) if match == "any" else len(hits) == len(scans) and bool(scans)
391
+ if ok:
392
+ rows.append({"symbol": t.symbol, "market": t.market, "name": (names or {}).get((t.symbol, t.market)),
393
+ **metrics, "matched": list(hits), "reasons": list(hits.values())})
394
+ rows.sort(key=lambda r: (-len(r["matched"]), -abs(r.get("change_percent") or 0)))
395
+ return {"rows": rows, "scanned": len(by_ticker) - len(failed), "failed": failed,
396
+ "seconds": round(time.time() - started, 1)}
397
+
398
+
399
+ # ------------------------------------------------------------------ universe
400
+
401
+
402
+ def resolve_universe(spec: dict, config=None) -> tuple[list[Target], dict]:
403
+ """spec: {sources: [top|watchlist|portfolio|custom], market, top_n,
404
+ watchlist_ids, portfolio_names, symbols} -> (targets, names)."""
405
+ from ..data.listings import get_store
406
+ from ..services.schedule_config import source_catalog
407
+
408
+ sources = spec.get("sources") or ["top"]
409
+ market = spec.get("market") or "india"
410
+ pairs: list[tuple[str, str]] = []
411
+ names: dict[tuple[str, str], str] = {}
412
+ if "top" in sources:
413
+ n = max(1, min(500, int(spec.get("top_n") or 100)))
414
+ rows = [r for r in get_store().rows(market) if r.get("type") in ("stock", "crypto")]
415
+ key = "volume" if market == "crypto" else "market_cap"
416
+ rows = sorted((r for r in rows if r.get(key)), key=lambda r: -r[key])[:n]
417
+ for r in rows:
418
+ pairs.append((r["symbol"], market))
419
+ names[(r["symbol"], market)] = r.get("name")
420
+ if {"watchlist", "portfolio"} & set(sources):
421
+ cat = source_catalog(config)
422
+ if "portfolio" in sources:
423
+ for p in cat["portfolios"]:
424
+ if not spec.get("portfolio_names") or p["name"] in spec["portfolio_names"]:
425
+ pairs += [(s["symbol"], s["market"]) for s in p["symbols"]]
426
+ if "watchlist" in sources:
427
+ for w in cat["watchlists"]:
428
+ if not spec.get("watchlist_ids") or w["id"] in spec["watchlist_ids"]:
429
+ pairs += [(s["symbol"], s["market"]) for s in w["symbols"]]
430
+ if "custom" in sources:
431
+ pairs += [(s.strip().upper(), market) for s in spec.get("symbols") or [] if s.strip()]
432
+ return [Target(s, m) for s, m in dict.fromkeys(pairs)], names
433
+
434
+
435
+ # ------------------------------------------------------------------ saved scans
436
+
437
+
438
+ def scan_markets(universe: dict, targets: list[Target]) -> set[str]:
439
+ return {t.market for t in targets} or {universe.get("market") or "india"}
440
+
441
+
442
+ def run_saved_scans(config, now=None, dispatcher=None, cache: Optional[BarCache] = None, force_id: Optional[int] = None) -> list[dict]:
443
+ """Run saved scans that are due (or just `force_id`), notify new matches.
444
+ Called from AlertMonitor's loop."""
445
+ from datetime import datetime, timedelta
446
+
447
+ from ..storage import DatabaseManager, SavedScanRecord, get_db_url
448
+ from .market_hours import is_open
449
+
450
+ now = now or datetime.now()
451
+ aware = now.astimezone() if now.tzinfo is None else now
452
+ naive = now.replace(tzinfo=None)
453
+ db = DatabaseManager.instance(get_db_url(config=config))
454
+ with db.get_session() as s:
455
+ q = s.query(SavedScanRecord)
456
+ q = q.filter(SavedScanRecord.id == force_id) if force_id else q.filter(SavedScanRecord.notify.is_(True))
457
+ saved = [{c.name: getattr(r, c.name) for c in SavedScanRecord.__table__.columns} for r in q]
458
+
459
+ results = []
460
+ for sc in saved:
461
+ if not force_id:
462
+ due = sc["last_run_at"] is None or naive - sc["last_run_at"] >= timedelta(minutes=sc["interval_minutes"] or 15)
463
+ if not due:
464
+ continue
465
+ targets, names = resolve_universe(sc["universe"] or {}, config)
466
+ if not force_id and not any(is_open(m, aware) for m in scan_markets(sc["universe"] or {}, targets)):
467
+ continue
468
+ out = run_scan(targets, sc["scans"] or [], sc["params"] or {}, sc["match"] or "any", cache=cache, names=names)
469
+ day = naive.strftime("%Y-%m-%d")
470
+ already = set(sc["notified_symbols"] or []) if sc["notified_day"] == day else set()
471
+ fresh = [r for r in out["rows"] if r["symbol"] not in already]
472
+ delivery = {}
473
+ if sc["notify"] and fresh and not force_id:
474
+ delivery = _notify_scan(config, sc["name"], fresh, dispatcher)
475
+ already |= {r["symbol"] for r in fresh}
476
+ with db.get_session() as s:
477
+ rec = s.get(SavedScanRecord, sc["id"])
478
+ if rec is not None:
479
+ rec.last_run_at = naive
480
+ rec.last_count = len(out["rows"])
481
+ rec.notified_day = day
482
+ rec.notified_symbols = sorted(already)
483
+ results.append({"id": sc["id"], "name": sc["name"], **out, "new": [r["symbol"] for r in fresh], "delivery": delivery})
484
+ return results
485
+
486
+
487
+ def _notify_scan(config, name: str, rows: list[dict], dispatcher=None) -> dict:
488
+ from ..notifiers.dispatcher import NotificationDispatcher
489
+
490
+ lines = [f"🔎 {name}: {len(rows)} new match{'es' if len(rows) != 1 else ''}"]
491
+ for r in rows[:15]:
492
+ lines.append(f"• {r['symbol']} {r['price']:,.2f} ({r['change_percent']:+.1f}%): {', '.join(r['reasons'][:2])}")
493
+ if len(rows) > 15:
494
+ lines.append(f"…and {len(rows) - 15} more (open Trade → Scanner)")
495
+ try:
496
+ dispatcher = dispatcher or NotificationDispatcher(config)
497
+ return dispatcher.send_all("\n".join(lines), title=f"Scan: {name}", purpose="alert")
498
+ except Exception as exc:
499
+ logger.error("Scan notification failed: %s", exc)
500
+ return {}
@@ -0,0 +1,163 @@
1
+ """Realized-volatility estimators and volatility cones on OHLC frames
2
+ (lower-case columns). Annualised, as fractions (0.25 = 25%).
3
+
4
+ Estimator formulas follow the OpenBB technical extension (Apache 2.0):
5
+ close-to-close, Parkinson, Garman-Klass, Rogers-Satchell and Yang-Zhang.
6
+ Range-based estimators use the high / low / open, so they read more from
7
+ the same number of bars than close-to-close does.
8
+
9
+ garch() fits a GARCH(1,1) by maximum likelihood (scipy, Gaussian) to forecast
10
+ volatility forward, the econometrics piece of OpenBB's quantitative extension.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ TRADING_DAYS = 252
21
+ ESTIMATORS = {
22
+ "close_to_close": "Close-to-close",
23
+ "parkinson": "Parkinson (high-low)",
24
+ "garman_klass": "Garman-Klass",
25
+ "rogers_satchell": "Rogers-Satchell",
26
+ "yang_zhang": "Yang-Zhang",
27
+ }
28
+
29
+
30
+ def _ann(var: pd.Series) -> pd.Series:
31
+ return np.sqrt(var.clip(lower=0) * TRADING_DAYS)
32
+
33
+
34
+ def realized(df: pd.DataFrame, window: int = 20, kind: str = "yang_zhang") -> pd.Series:
35
+ o, h, l, c = (df[k].astype(float) for k in ("open", "high", "low", "close"))
36
+ roll = lambda s: s.rolling(window, min_periods=window).mean() # noqa: E731
37
+ if kind == "close_to_close":
38
+ return np.log(c / c.shift(1)).rolling(window, min_periods=window).std() * math.sqrt(TRADING_DAYS)
39
+ hl = np.log(h / l)
40
+ if kind == "parkinson":
41
+ return _ann(roll(hl ** 2) / (4 * math.log(2)))
42
+ co = np.log(c / o)
43
+ if kind == "garman_klass":
44
+ return _ann(roll(0.5 * hl ** 2 - (2 * math.log(2) - 1) * co ** 2))
45
+ rs = np.log(h / c) * np.log(h / o) + np.log(l / c) * np.log(l / o)
46
+ if kind == "rogers_satchell":
47
+ return _ann(roll(rs))
48
+ if kind == "yang_zhang":
49
+ overnight = np.log(o / c.shift(1))
50
+ k = 0.34 / (1.34 + (window + 1) / (window - 1))
51
+ var = overnight.rolling(window).var() + k * co.rolling(window).var() + (1 - k) * roll(rs)
52
+ return _ann(var)
53
+ raise ValueError(f"unknown estimator {kind}")
54
+
55
+
56
+ def cones(df: pd.DataFrame, windows=(10, 20, 60, 120), kind: str = "yang_zhang") -> list[dict]:
57
+ """Per window: today's realized vol and its historical min / quartiles / max."""
58
+ out = []
59
+ for w in windows:
60
+ s = realized(df, w, kind).dropna()
61
+ if len(s) < max(20, w // 2):
62
+ continue
63
+ q = s.quantile([0.25, 0.5, 0.75])
64
+ out.append({"window": w, "current": float(s.iloc[-1]), "min": float(s.min()), "p25": float(q[0.25]),
65
+ "median": float(q[0.5]), "p75": float(q[0.75]), "max": float(s.max()),
66
+ "percentile": float((s <= s.iloc[-1]).mean() * 100)})
67
+ return out
68
+
69
+
70
+ def _garch_filter(r: np.ndarray, omega: float, alpha: float, beta: float) -> np.ndarray:
71
+ var = np.empty(len(r))
72
+ var[0] = r.var()
73
+ for t in range(1, len(r)):
74
+ var[t] = omega + alpha * r[t - 1] ** 2 + beta * var[t - 1]
75
+ return var
76
+
77
+
78
+ def garch(close: pd.Series, horizons=(5, 21)) -> dict | None:
79
+ """GARCH(1,1) on daily log returns: current and forecast annualised volatility.
80
+
81
+ sigma2[t] = omega + alpha * r[t-1]^2 + beta * sigma2[t-1]. Forecasts revert to
82
+ the long-run level omega / (1 - alpha - beta) at rate alpha + beta per day.
83
+ Needs about a year of closes; returns None when the fit is not usable.
84
+ """
85
+ from scipy.optimize import minimize
86
+
87
+ c = close.dropna()
88
+ c = c[c > 0]
89
+ r = np.diff(np.log(c.to_numpy(dtype=float))) * 100 # percent, better conditioned
90
+ if len(r) < 200:
91
+ return None
92
+ r = r - r.mean()
93
+ v0 = float(r.var())
94
+
95
+ def nll(p):
96
+ omega, alpha, beta = p
97
+ if omega <= 0 or alpha < 0 or beta < 0 or alpha + beta >= 0.999:
98
+ return 1e10
99
+ var = _garch_filter(r, omega, alpha, beta)
100
+ return 0.5 * float(np.sum(np.log(var) + r ** 2 / var))
101
+
102
+ best = None
103
+ for a, b in ((0.05, 0.9), (0.1, 0.8), (0.03, 0.95)):
104
+ res = minimize(nll, [v0 * (1 - a - b), a, b], method="Nelder-Mead",
105
+ options={"maxiter": 3000, "xatol": 1e-8, "fatol": 1e-8})
106
+ if res.fun < 1e9 and (best is None or res.fun < best.fun):
107
+ best = res
108
+ if best is None:
109
+ return None
110
+ omega, alpha, beta = (float(x) for x in best.x)
111
+ persistence = alpha + beta
112
+ long_run = omega / (1 - persistence)
113
+ var = _garch_filter(r, omega, alpha, beta)
114
+ nxt = omega + alpha * r[-1] ** 2 + beta * var[-1] # tomorrow's variance
115
+
116
+ def ann(daily_pct_var: float) -> float:
117
+ return math.sqrt(daily_pct_var * TRADING_DAYS) / 100
118
+
119
+ forecasts = []
120
+ for h in horizons:
121
+ # mean variance over the next h days
122
+ k = np.arange(h)
123
+ path = long_run + (persistence ** k) * (nxt - long_run)
124
+ forecasts.append({"days": int(h), "volatility": ann(float(path.mean()))})
125
+ return {
126
+ "current": ann(float(var[-1])),
127
+ "next_day": ann(float(nxt)),
128
+ "long_run": ann(long_run),
129
+ "forecasts": forecasts,
130
+ "alpha": alpha,
131
+ "beta": beta,
132
+ "persistence": persistence,
133
+ "half_life_days": math.log(0.5) / math.log(persistence) if 0 < persistence < 1 else None,
134
+ }
135
+
136
+
137
+ def summary(df: pd.DataFrame, window: int = 20) -> dict:
138
+ """Current value of every estimator, cones, and a calm / normal / stormy regime."""
139
+ d = df.dropna(subset=["open", "high", "low", "close"])
140
+ d = d[(d[["open", "high", "low", "close"]] > 0).all(axis=1)]
141
+ if len(d) < window + 5:
142
+ raise ValueError("not enough OHLC history for volatility")
143
+ now = {k: realized(d, window, k).iloc[-1] for k in ESTIMATORS}
144
+ cone = cones(d)
145
+ ref = next((c for c in cone if c["window"] == window), None)
146
+ regime = None
147
+ if ref:
148
+ regime = "stormy" if ref["percentile"] >= 80 else "calm" if ref["percentile"] <= 20 else "normal"
149
+ return {
150
+ "window": window,
151
+ "estimators": [{"key": k, "name": ESTIMATORS[k], "value": float(v) if v == v else None} for k, v in now.items()],
152
+ "cones": cone,
153
+ "regime": regime,
154
+ "percentile": ref["percentile"] if ref else None,
155
+ "garch": _safe_garch(d["close"]),
156
+ }
157
+
158
+
159
+ def _safe_garch(close: pd.Series) -> dict | None:
160
+ try:
161
+ return garch(close)
162
+ except Exception:
163
+ return None
@@ -0,0 +1 @@
1
+ """Utility functions and helpers."""