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,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."""
|