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,398 @@
|
|
|
1
|
+
"""Ready-made fundamental screens (OpenBB equity.discovery: undervalued large
|
|
2
|
+
caps, growth at a reasonable price, ...) built on free data.
|
|
3
|
+
|
|
4
|
+
The universe is the largest stocks of a market by market cap from our own
|
|
5
|
+
listings; each is enriched with Yahoo fundamentals (shared 6-hour cache with
|
|
6
|
+
the Compare page) and checked against the preset's rules. The first run of a
|
|
7
|
+
market takes ~20-40 s; later presets reuse the cache.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
from ..core.numbers import finite as _f
|
|
16
|
+
from ..core.numbers import median as _median
|
|
17
|
+
|
|
18
|
+
UNIVERSE = 200
|
|
19
|
+
LARGE = 100 # "large cap" = top 100 by market cap in that market
|
|
20
|
+
|
|
21
|
+
# field -> (yahoo key, transform)
|
|
22
|
+
FIELDS: dict[str, tuple[str, Callable[[float], float]]] = {
|
|
23
|
+
"pe": ("trailingPE", lambda v: v),
|
|
24
|
+
"forward_pe": ("forwardPE", lambda v: v),
|
|
25
|
+
"pb": ("priceToBook", lambda v: v),
|
|
26
|
+
"peg": ("trailingPegRatio", lambda v: v),
|
|
27
|
+
"roe": ("returnOnEquity", lambda v: v * 100),
|
|
28
|
+
"debt_equity": ("debtToEquity", lambda v: v / 100),
|
|
29
|
+
"dividend_yield": ("dividendYield", lambda v: v), # Yahoo already gives percent
|
|
30
|
+
"payout": ("payoutRatio", lambda v: v * 100),
|
|
31
|
+
"earnings_growth": ("earningsGrowth", lambda v: v * 100),
|
|
32
|
+
"revenue_growth": ("revenueGrowth", lambda v: v * 100),
|
|
33
|
+
"operating_margin": ("operatingMargins", lambda v: v * 100),
|
|
34
|
+
# Extra fields for custom screens (the presets don't use them).
|
|
35
|
+
"ps": ("priceToSalesTrailing12Months", lambda v: v),
|
|
36
|
+
"ev_ebitda": ("enterpriseToEbitda", lambda v: v),
|
|
37
|
+
"ev_revenue": ("enterpriseToRevenue", lambda v: v),
|
|
38
|
+
"roa": ("returnOnAssets", lambda v: v * 100),
|
|
39
|
+
"gross_margin": ("grossMargins", lambda v: v * 100),
|
|
40
|
+
"ebitda_margin": ("ebitdaMargins", lambda v: v * 100),
|
|
41
|
+
"profit_margin": ("profitMargins", lambda v: v * 100),
|
|
42
|
+
"earnings_q_growth": ("earningsQuarterlyGrowth", lambda v: v * 100),
|
|
43
|
+
"avg_dividend_yield_5y": ("fiveYearAvgDividendYield", lambda v: v), # percent already
|
|
44
|
+
"current_ratio": ("currentRatio", lambda v: v),
|
|
45
|
+
"quick_ratio": ("quickRatio", lambda v: v),
|
|
46
|
+
"beta": ("beta", lambda v: v),
|
|
47
|
+
"change_52w": ("52WeekChange", lambda v: v * 100),
|
|
48
|
+
"analyst_rating": ("recommendationMean", lambda v: v),
|
|
49
|
+
"analysts": ("numberOfAnalystOpinions", lambda v: v),
|
|
50
|
+
"insiders": ("heldPercentInsiders", lambda v: v * 100),
|
|
51
|
+
"institutions": ("heldPercentInstitutions", lambda v: v * 100),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
LABELS = {
|
|
55
|
+
"pe": "P/E", "forward_pe": "Fwd P/E", "pb": "P/B", "peg": "PEG", "roe": "ROE %", "debt_equity": "Debt/Eq",
|
|
56
|
+
"dividend_yield": "Div yield %", "payout": "Payout %", "earnings_growth": "EPS growth %",
|
|
57
|
+
"revenue_growth": "Rev growth %", "operating_margin": "Op margin %", "from_high": "From 52w high %",
|
|
58
|
+
"from_low": "Above 52w low %",
|
|
59
|
+
"sector": "Sector", "sector_pe": "Sector P/E", "pe_vs_sector": "P/E × sector", "pb_vs_sector": "P/B × sector",
|
|
60
|
+
"peg_calc": "PEG", "growth_short": "earnings growth too low for the P/E (PEG > 2 or shrinking)",
|
|
61
|
+
"ps": "P/S", "ev_ebitda": "EV/EBITDA", "ev_revenue": "EV/Revenue", "earnings_yield": "Earnings yield %",
|
|
62
|
+
"fcf_yield": "FCF yield %", "roa": "ROA %", "gross_margin": "Gross margin %", "ebitda_margin": "EBITDA margin %",
|
|
63
|
+
"profit_margin": "Net margin %", "earnings_q_growth": "Quarterly EPS growth %", "forward_eps_growth": "Fwd EPS growth %",
|
|
64
|
+
"avg_dividend_yield_5y": "5y avg div yield %", "current_ratio": "Current ratio", "quick_ratio": "Quick ratio",
|
|
65
|
+
"beta": "Beta", "change_52w": "1y return %", "market_cap": "Market cap", "price": "Price",
|
|
66
|
+
"target_upside": "Target upside %", "analyst_rating": "Analyst rating", "analysts": "Analysts",
|
|
67
|
+
"insiders": "Insiders %", "institutions": "Institutions %",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Custom-screen catalogue: group -> metrics, with a hint for the builder.
|
|
71
|
+
CATALOGUE: list[tuple[str, list[tuple[str, str]]]] = [
|
|
72
|
+
("Valuation", [("pe", "Price / trailing earnings per share"), ("forward_pe", "Price / next year's expected earnings"),
|
|
73
|
+
("pb", "Price / book value per share"), ("ps", "Market cap / yearly revenue"),
|
|
74
|
+
("peg", "P/E / expected earnings growth"), ("ev_ebitda", "Enterprise value / EBITDA"),
|
|
75
|
+
("ev_revenue", "Enterprise value / revenue"), ("earnings_yield", "Earnings / price, the P/E upside down"),
|
|
76
|
+
("fcf_yield", "Free cash flow / market cap")]),
|
|
77
|
+
("Profitability", [("roe", "Profit / shareholders' equity"), ("roa", "Profit / total assets"),
|
|
78
|
+
("gross_margin", "Gross profit / revenue"), ("operating_margin", "Operating profit / revenue"),
|
|
79
|
+
("ebitda_margin", "EBITDA / revenue"), ("profit_margin", "Net profit / revenue")]),
|
|
80
|
+
("Growth", [("revenue_growth", "Latest quarter vs the same quarter a year earlier"),
|
|
81
|
+
("earnings_growth", "Latest quarter EPS vs a year earlier"),
|
|
82
|
+
("earnings_q_growth", "Latest quarter net profit vs a year earlier"),
|
|
83
|
+
("forward_eps_growth", "Next year's expected EPS vs the last 12 months")]),
|
|
84
|
+
("Dividends", [("dividend_yield", "Yearly dividend / price"), ("avg_dividend_yield_5y", "Average yield over 5 years"),
|
|
85
|
+
("payout", "Share of profit paid as dividends")]),
|
|
86
|
+
("Balance sheet", [("debt_equity", "Total debt / equity"), ("current_ratio", "Current assets / current liabilities"),
|
|
87
|
+
("quick_ratio", "Current assets minus stock / current liabilities")]),
|
|
88
|
+
("Price & size", [("market_cap", "In the listing's currency"), ("price", "Last price"),
|
|
89
|
+
("change_52w", "Price change over the past year"), ("from_high", "Distance below the 52-week high (negative)"),
|
|
90
|
+
("from_low", "Distance above the 52-week low"), ("beta", "Moves vs the market: 1 = same, above 1 = swings more")]),
|
|
91
|
+
("Analysts", [("target_upside", "Average target price vs the current price"),
|
|
92
|
+
("analyst_rating", "1 = strong buy, 3 = hold, 5 = sell"), ("analysts", "Analysts covering the stock")]),
|
|
93
|
+
("Ownership", [("insiders", "Shares held by promoters / insiders"), ("institutions", "Shares held by institutions")]),
|
|
94
|
+
]
|
|
95
|
+
CUSTOM_FIELDS = {k for _, items in CATALOGUE for k, _ in items}
|
|
96
|
+
# Ratios whose sector median only means something over positive values.
|
|
97
|
+
POSITIVE_ONLY = {"pe", "forward_pe", "pb", "ps", "peg", "ev_ebitda", "ev_revenue"}
|
|
98
|
+
CUSTOM_OPS = (">", "<", "between", "top", "bottom", "below_sector", "above_sector")
|
|
99
|
+
MAX_RULES = 12
|
|
100
|
+
|
|
101
|
+
# Ratios the feed works out from statements in the reporting currency against
|
|
102
|
+
# a price in the listing currency (TSM: TWD accounts, USD shares), so they are
|
|
103
|
+
# wrong by the exchange rate whenever the two differ.
|
|
104
|
+
MIXED_CURRENCY = ("pb", "ps", "ev_ebitda", "ev_revenue", "fcf_yield")
|
|
105
|
+
|
|
106
|
+
SECTOR_MIN_PEERS = 5 # profitable companies a sector needs before its median means anything
|
|
107
|
+
|
|
108
|
+
# key: title, description, large caps only?, [(field, op, value)], sort (field, descending), columns
|
|
109
|
+
PRESETS = {
|
|
110
|
+
"undervalued_large": ("Low-multiple large caps", "Large, profitable companies with P/E under 18 and P/B under 3",
|
|
111
|
+
True, [("pe", "between", (0, 18)), ("pb", "<", 3), ("roe", ">", 12), ("earnings_growth", ">", 0)],
|
|
112
|
+
("pe", False), ["pe", "pb", "roe", "earnings_growth", "dividend_yield"]),
|
|
113
|
+
"garp": ("Growth at a modest P/E", "EPS growth over 15% and revenue growth over 8% with P/E under 30",
|
|
114
|
+
False, [("earnings_growth", ">", 15), ("revenue_growth", ">", 8), ("pe", "between", (0, 30)), ("roe", ">", 15),
|
|
115
|
+
("debt_equity", "<", 1)],
|
|
116
|
+
("earnings_growth", True), ["pe", "earnings_growth", "revenue_growth", "roe", "debt_equity"]),
|
|
117
|
+
"dividend": ("High dividend, low debt", "Dividend yield over 3%, debt under half of equity, payout under 85%",
|
|
118
|
+
False, [("dividend_yield", ">", 3), ("debt_equity", "<", 0.5), ("payout", "between", (0, 85))],
|
|
119
|
+
("dividend_yield", True), ["dividend_yield", "payout", "debt_equity", "pe", "roe"]),
|
|
120
|
+
"quality": ("High ROE, low debt", "ROE over 20%, operating margin over 18%, little debt, revenue still growing",
|
|
121
|
+
False, [("roe", ">", 20), ("operating_margin", ">", 18), ("debt_equity", "<", 0.5), ("revenue_growth", ">", 8)],
|
|
122
|
+
("roe", True), ["roe", "operating_margin", "revenue_growth", "debt_equity", "pe"]),
|
|
123
|
+
"debt_free": ("Debt-free and profitable", "Debt under 0.1x equity and ROE over 15%",
|
|
124
|
+
False, [("debt_equity", "<", 0.1), ("roe", ">", 15), ("pe", ">", 0)],
|
|
125
|
+
("roe", True), ["debt_equity", "roe", "pe", "operating_margin", "dividend_yield"]),
|
|
126
|
+
# Relative, not absolute: a P/E of 40 is cheap for one sector and dear for
|
|
127
|
+
# another, so each stock is compared with the median of its own sector
|
|
128
|
+
# among the same largest-200 universe (see _sector_relative).
|
|
129
|
+
"expensive_sector": ("P/E far above sector", "P/E over 1.5x the sector median while earnings growth trails the sector",
|
|
130
|
+
False, [("pe_vs_sector", ">", 1.5), ("growth_short", "is", True)],
|
|
131
|
+
("pe_vs_sector", True), ["sector", "pe", "sector_pe", "pe_vs_sector", "pb_vs_sector", "earnings_growth", "peg_calc"]),
|
|
132
|
+
"near_low": ("Quality near 52-week low", "Profitable companies trading close to their yearly low",
|
|
133
|
+
False, [("from_low", "<", 10), ("roe", ">", 12), ("pe", ">", 0)],
|
|
134
|
+
("from_low", False), ["from_low", "from_high", "pe", "roe", "debt_equity"]),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
InfoFetch = Callable[[str], dict]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def metrics(info: dict) -> dict:
|
|
141
|
+
out = {}
|
|
142
|
+
for field, (key, fn) in FIELDS.items():
|
|
143
|
+
v = _f(info.get(key))
|
|
144
|
+
out[field] = fn(v) if v is not None else None
|
|
145
|
+
price, hi, lo = (_f(info.get(k)) for k in ("currentPrice", "fiftyTwoWeekHigh", "fiftyTwoWeekLow"))
|
|
146
|
+
out["from_high"] = (price / hi - 1) * 100 if price and hi else None
|
|
147
|
+
out["from_low"] = (price / lo - 1) * 100 if price and lo else None
|
|
148
|
+
pe, cap, fcf = out["pe"], _f(info.get("marketCap")), _f(info.get("freeCashflow"))
|
|
149
|
+
out["earnings_yield"] = 100 / pe if pe and pe > 0 else None
|
|
150
|
+
out["fcf_yield"] = fcf / cap * 100 if fcf is not None and cap else None
|
|
151
|
+
fwd, trail = _f(info.get("forwardEps")), _f(info.get("trailingEps"))
|
|
152
|
+
out["forward_eps_growth"] = (fwd / trail - 1) * 100 if fwd is not None and trail and trail > 0 else None
|
|
153
|
+
target = _f(info.get("targetMeanPrice"))
|
|
154
|
+
out["target_upside"] = (target / price - 1) * 100 if target and price else None
|
|
155
|
+
books, quote = info.get("financialCurrency"), info.get("currency")
|
|
156
|
+
if books and quote and books != quote:
|
|
157
|
+
for field in MIXED_CURRENCY:
|
|
158
|
+
out[field] = None
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _passes(m: dict, rules: list) -> bool:
|
|
163
|
+
for field, op, value in rules:
|
|
164
|
+
v = m.get(field)
|
|
165
|
+
if v is None:
|
|
166
|
+
return False
|
|
167
|
+
if op == ">" and not v > value:
|
|
168
|
+
return False
|
|
169
|
+
if op == "<" and not v < value:
|
|
170
|
+
return False
|
|
171
|
+
if op == "between" and not value[0] < v < value[1]:
|
|
172
|
+
return False
|
|
173
|
+
if op == "is" and v is not value:
|
|
174
|
+
return False
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def list_presets() -> list[dict]:
|
|
179
|
+
return [{"key": k, "title": p[0], "description": p[1], "large_only": p[2],
|
|
180
|
+
"rules": [_rule_text(r) for r in p[3]]} for k, p in PRESETS.items()]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _rule_text(rule) -> str:
|
|
184
|
+
field, op, value = rule
|
|
185
|
+
label = LABELS.get(field, field)
|
|
186
|
+
if op == "between":
|
|
187
|
+
return f"{label} {value[0]:g}–{value[1]:g}"
|
|
188
|
+
if op == "is":
|
|
189
|
+
return label[:1].upper() + label[1:]
|
|
190
|
+
return f"{label} {op} {value:g}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _sector_relative(ms: list[dict]) -> None:
|
|
194
|
+
"""Adds sector_pe, pe_vs_sector, pb_vs_sector, peg_calc and growth_short
|
|
195
|
+
in place. Medians use profitable companies only (P/E > 0) and need
|
|
196
|
+
SECTOR_MIN_PEERS of them; otherwise the relative fields stay None."""
|
|
197
|
+
by_sector: dict[str, list[dict]] = {}
|
|
198
|
+
for m in ms:
|
|
199
|
+
if m.get("sector") and (m.get("pe") or 0) > 0:
|
|
200
|
+
by_sector.setdefault(m["sector"], []).append(m)
|
|
201
|
+
for m in ms:
|
|
202
|
+
pe, pb, growth = m.get("pe"), m.get("pb"), m.get("earnings_growth")
|
|
203
|
+
peers = by_sector.get(m.get("sector") or "", [])
|
|
204
|
+
m["sector_pe"] = m["pe_vs_sector"] = m["pb_vs_sector"] = None
|
|
205
|
+
if len(peers) >= SECTOR_MIN_PEERS and pe and pe > 0:
|
|
206
|
+
sector_pe = _median([p["pe"] for p in peers])
|
|
207
|
+
sector_pb = _median([p["pb"] for p in peers if (p.get("pb") or 0) > 0])
|
|
208
|
+
m["sector_pe"] = sector_pe
|
|
209
|
+
m["pe_vs_sector"] = pe / sector_pe if sector_pe else None
|
|
210
|
+
m["pb_vs_sector"] = pb / sector_pb if pb and pb > 0 and sector_pb else None
|
|
211
|
+
m["peg_calc"] = pe / growth if pe and pe > 0 and growth and growth > 0 else None
|
|
212
|
+
# Growth that doesn't carry the multiple: shrinking earnings, or PEG > 2.
|
|
213
|
+
m["growth_short"] = (growth is not None and growth <= 0) or (m["peg_calc"] is not None and m["peg_calc"] > 2)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _measure(market: str, large_only: bool, info_fetch: Optional[InfoFetch], rows: Optional[list[dict]]) -> list[tuple]:
|
|
217
|
+
"""(listing row, info, metrics) for the largest stocks of a market, with
|
|
218
|
+
the sector-relative fields filled in."""
|
|
219
|
+
from .analysis_run import yahoo_symbol
|
|
220
|
+
from .compare import _yahoo_info
|
|
221
|
+
|
|
222
|
+
info_fetch = info_fetch or _yahoo_info
|
|
223
|
+
if rows is None:
|
|
224
|
+
from ..data.listings import get_store
|
|
225
|
+
|
|
226
|
+
rows = get_store().rows(market)
|
|
227
|
+
stocks = sorted((r for r in rows if r.get("type") == "stock" and r.get("market_cap")),
|
|
228
|
+
key=lambda r: -r["market_cap"])[:UNIVERSE]
|
|
229
|
+
if large_only:
|
|
230
|
+
stocks = stocks[:LARGE]
|
|
231
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
232
|
+
infos = list(pool.map(lambda r: info_fetch(yahoo_symbol(r["symbol"], market)), stocks))
|
|
233
|
+
|
|
234
|
+
measured = [(r, info, metrics(info)) for r, info in zip(stocks, infos) if info]
|
|
235
|
+
for r, info, m in measured:
|
|
236
|
+
m["sector"] = info.get("sector") or r.get("sector")
|
|
237
|
+
m["market_cap"] = r["market_cap"]
|
|
238
|
+
m["price"] = r.get("price")
|
|
239
|
+
_sector_relative([m for _, _, m in measured])
|
|
240
|
+
return measured
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _hits(measured: list[tuple], market: str, passes: Callable[[dict], bool], columns: list[str],
|
|
244
|
+
sort_field: str, desc_sort: bool) -> list[dict]:
|
|
245
|
+
hits, seen = [], set()
|
|
246
|
+
for r, info, m in measured:
|
|
247
|
+
company = (info.get("longName") or info.get("shortName") or r["symbol"]).lower()
|
|
248
|
+
if company in seen: # second share class of the same company (BRK-A / BRK-B)
|
|
249
|
+
continue
|
|
250
|
+
if passes(m):
|
|
251
|
+
seen.add(company)
|
|
252
|
+
hits.append({"symbol": r["symbol"], "name": r.get("name"), "market": market,
|
|
253
|
+
"market_cap": r["market_cap"], "price": r.get("price"), "currency": r.get("currency"),
|
|
254
|
+
"change_percent": r.get("change_percent"), "sector": m["sector"],
|
|
255
|
+
**{c: m.get(c) for c in columns}})
|
|
256
|
+
hits.sort(key=lambda h: (h.get(sort_field) is None, -(h.get(sort_field) or 0) if desc_sort else (h.get(sort_field) or 0)))
|
|
257
|
+
return hits
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run(key: str, market: str, info_fetch: Optional[InfoFetch] = None, rows: Optional[list[dict]] = None) -> dict:
|
|
261
|
+
if key not in PRESETS:
|
|
262
|
+
raise ValueError(f"Unknown screen '{key}'")
|
|
263
|
+
title, desc, large_only, rules, (sort_field, desc_sort), columns = PRESETS[key]
|
|
264
|
+
measured = _measure(market, large_only, info_fetch, rows)
|
|
265
|
+
hits = _hits(measured, market, lambda m: _passes(m, rules), columns, sort_field, desc_sort)
|
|
266
|
+
return {
|
|
267
|
+
"key": key, "title": title, "description": desc, "market": market,
|
|
268
|
+
"rules": [_rule_text(r) for r in rules],
|
|
269
|
+
"columns": [{"key": c, "label": LABELS.get(c, c)} for c in columns],
|
|
270
|
+
"universe": f"Largest {LARGE if large_only else UNIVERSE} stocks by market cap",
|
|
271
|
+
"scanned": len(measured),
|
|
272
|
+
"results": hits,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---- custom screens ---------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def catalogue() -> list[dict]:
|
|
279
|
+
return [{"group": g, "metrics": [{"key": k, "label": LABELS[k], "hint": h} for k, h in items]} for g, items in CATALOGUE]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class RuleError(ValueError):
|
|
283
|
+
"""A custom-screen rule the user has to fix; the message is shown as is."""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def validate(rules: list[dict]) -> list[dict]:
|
|
287
|
+
"""Clean rules: known field and op, numeric values, `between` ordered.
|
|
288
|
+
Raises RuleError with a message fit for the user."""
|
|
289
|
+
if not rules:
|
|
290
|
+
raise RuleError("Add at least one rule")
|
|
291
|
+
if len(rules) > MAX_RULES:
|
|
292
|
+
raise RuleError(f"At most {MAX_RULES} rules")
|
|
293
|
+
out = []
|
|
294
|
+
for r in rules:
|
|
295
|
+
field, op = r.get("field"), r.get("op")
|
|
296
|
+
if field not in CUSTOM_FIELDS:
|
|
297
|
+
raise RuleError(f"Unknown metric '{field}'")
|
|
298
|
+
if op not in CUSTOM_OPS:
|
|
299
|
+
raise RuleError(f"Unknown operator '{op}'")
|
|
300
|
+
value = _f(r.get("value"))
|
|
301
|
+
if value is None and op not in ("below_sector", "above_sector"):
|
|
302
|
+
raise RuleError(f"{LABELS[field]}: enter a number")
|
|
303
|
+
rule = {"field": field, "op": op, "value": value}
|
|
304
|
+
if op == "between":
|
|
305
|
+
hi = _f(r.get("value2"))
|
|
306
|
+
if hi is None:
|
|
307
|
+
raise RuleError(f"{LABELS[field]}: enter both ends of the range")
|
|
308
|
+
rule["value"], rule["value2"] = min(value, hi), max(value, hi)
|
|
309
|
+
if op in ("top", "bottom") and not 0 < value <= 100:
|
|
310
|
+
raise RuleError(f"{LABELS[field]}: top / bottom takes a percent from 1 to 100")
|
|
311
|
+
if op in ("below_sector", "above_sector"):
|
|
312
|
+
rule["value"] = value if value and value > 0 else 1.0
|
|
313
|
+
out.append(rule)
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def custom_rule_text(rule: dict) -> str:
|
|
318
|
+
label, op, v = LABELS[rule["field"]], rule["op"], rule["value"]
|
|
319
|
+
if op == "between":
|
|
320
|
+
return f"{label} {v:g}–{rule['value2']:g}"
|
|
321
|
+
if op in ("top", "bottom"):
|
|
322
|
+
return f"{label} in the {op} {v:g}%"
|
|
323
|
+
if op in ("below_sector", "above_sector"):
|
|
324
|
+
side = "below" if op == "below_sector" else "above"
|
|
325
|
+
return f"{label} {side} sector median" + (f" × {v:g}" if v != 1 else "")
|
|
326
|
+
return f"{label} {op} {v:g}"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _percentile_cut(values: list[float], pct: float, top: bool) -> float:
|
|
330
|
+
"""Value a stock must reach to be in the top (or bottom) `pct` percent."""
|
|
331
|
+
ordered = sorted(values, reverse=top)
|
|
332
|
+
n = max(1, round(len(ordered) * pct / 100))
|
|
333
|
+
return ordered[min(n, len(ordered)) - 1]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _sector_medians(ms: list[dict], field: str) -> dict[str, float]:
|
|
337
|
+
by: dict[str, list[float]] = {}
|
|
338
|
+
for m in ms:
|
|
339
|
+
v = m.get(field)
|
|
340
|
+
if m.get("sector") and v is not None and (v > 0 or field not in POSITIVE_ONLY):
|
|
341
|
+
by.setdefault(m["sector"], []).append(v)
|
|
342
|
+
return {s: _median(vs) for s, vs in by.items() if len(vs) >= SECTOR_MIN_PEERS}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def run_custom(market: str, rules: list[dict], sectors: Optional[list[str]] = None, sort: Optional[str] = None,
|
|
346
|
+
descending: bool = True, columns: Optional[list[str]] = None, large_only: bool = False,
|
|
347
|
+
info_fetch: Optional[InfoFetch] = None, rows: Optional[list[dict]] = None) -> dict:
|
|
348
|
+
"""Run user rules (see validate) over the same universe as the presets.
|
|
349
|
+
Top / bottom % and sector medians are taken over the stocks left after
|
|
350
|
+
the sector filter, so "top 10% ROE in Banks" ranks banks only."""
|
|
351
|
+
rules = validate(rules)
|
|
352
|
+
measured = _measure(market, large_only, info_fetch, rows)
|
|
353
|
+
all_sectors = sorted({m["sector"] for _, _, m in measured if m.get("sector")})
|
|
354
|
+
wanted = {s.lower() for s in sectors or []}
|
|
355
|
+
if wanted:
|
|
356
|
+
measured = [t for t in measured if (t[2].get("sector") or "").lower() in wanted]
|
|
357
|
+
ms = [m for _, _, m in measured]
|
|
358
|
+
|
|
359
|
+
checks: list[Callable[[dict], bool]] = []
|
|
360
|
+
for rule in rules:
|
|
361
|
+
field, op, v = rule["field"], rule["op"], rule["value"]
|
|
362
|
+
if op in ("top", "bottom"):
|
|
363
|
+
values = [m[field] for m in ms if m.get(field) is not None]
|
|
364
|
+
cut = _percentile_cut(values, v, op == "top") if values else None
|
|
365
|
+
checks.append(lambda m, f=field, c=cut, top=op == "top": c is not None and m.get(f) is not None
|
|
366
|
+
and (m[f] >= c if top else m[f] <= c))
|
|
367
|
+
elif op in ("below_sector", "above_sector"):
|
|
368
|
+
med = _sector_medians(ms, field)
|
|
369
|
+
|
|
370
|
+
def sector_check(m, f=field, med=med, k=v, below=op == "below_sector"):
|
|
371
|
+
x, base = m.get(f), med.get(m.get("sector") or "")
|
|
372
|
+
if x is None or base is None or (f in POSITIVE_ONLY and x <= 0):
|
|
373
|
+
return False
|
|
374
|
+
return x < base * k if below else x > base * k
|
|
375
|
+
checks.append(sector_check)
|
|
376
|
+
elif op == "between":
|
|
377
|
+
checks.append(lambda m, f=field, lo=v, hi=rule["value2"]: m.get(f) is not None and lo <= m[f] <= hi)
|
|
378
|
+
elif op == ">":
|
|
379
|
+
checks.append(lambda m, f=field, x=v: m.get(f) is not None and m[f] > x)
|
|
380
|
+
else:
|
|
381
|
+
checks.append(lambda m, f=field, x=v: m.get(f) is not None and m[f] < x)
|
|
382
|
+
|
|
383
|
+
used = list(dict.fromkeys([r["field"] for r in rules] + [c for c in columns or [] if c in CUSTOM_FIELDS]))
|
|
384
|
+
used = [c for c in used if c not in ("market_cap", "price")] # always shown
|
|
385
|
+
sort = sort if sort in CUSTOM_FIELDS else rules[0]["field"]
|
|
386
|
+
hits = _hits(measured, market, lambda m: all(c(m) for c in checks), used, sort, descending)
|
|
387
|
+
text = [custom_rule_text(r) for r in rules]
|
|
388
|
+
if wanted:
|
|
389
|
+
text.insert(0, "Sector: " + ", ".join(s for s in all_sectors if s.lower() in wanted))
|
|
390
|
+
return {
|
|
391
|
+
"key": "custom", "title": "Your screen", "description": "Your own rules", "market": market,
|
|
392
|
+
"rules": text,
|
|
393
|
+
"columns": [{"key": c, "label": LABELS.get(c, c)} for c in used],
|
|
394
|
+
"universe": f"Largest {LARGE if large_only else UNIVERSE} stocks by market cap" + (" in the chosen sectors" if wanted else ""),
|
|
395
|
+
"scanned": len(measured),
|
|
396
|
+
"sectors": all_sectors,
|
|
397
|
+
"results": hits,
|
|
398
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""AI summary of Management's Discussion and Analysis (MD&A) from a US
|
|
2
|
+
company's latest 10-K or 10-Q on SEC EDGAR (free, no key).
|
|
3
|
+
|
|
4
|
+
SEC's fair-access policy requires a User-Agent naming the caller with a
|
|
5
|
+
contact email, so the feature is off until SEC_USER_AGENT is set, e.g.
|
|
6
|
+
"MarketMyna you@example.com". Stands in for OpenBB's
|
|
7
|
+
equity.fundamental.management_discussion_analysis (Apache 2.0).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
|
|
21
|
+
SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik:010d}.json"
|
|
22
|
+
DOC_URL = "https://www.sec.gov/Archives/edgar/data/{cik}/{acc}/{doc}"
|
|
23
|
+
MAX_CHARS = 24000
|
|
24
|
+
CACHE_SECONDS = 24 * 3600
|
|
25
|
+
|
|
26
|
+
_cache: dict[str, tuple[float, object]] = {}
|
|
27
|
+
_lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SecNotConfigured(RuntimeError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def user_agent() -> str:
|
|
35
|
+
ua = os.environ.get("SEC_USER_AGENT", "").strip()
|
|
36
|
+
if "@" not in ua:
|
|
37
|
+
raise SecNotConfigured("Set SEC_USER_AGENT (a name and contact email, e.g. 'MarketMyna you@example.com') "
|
|
38
|
+
"to read SEC filings - SEC requires it.")
|
|
39
|
+
return ua
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get(url: str):
|
|
43
|
+
import requests
|
|
44
|
+
|
|
45
|
+
resp = requests.get(url, headers={"User-Agent": user_agent(), "Accept-Encoding": "gzip, deflate"}, timeout=30)
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
return resp
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cached(key: str, fn):
|
|
51
|
+
with _lock:
|
|
52
|
+
hit = _cache.get(key)
|
|
53
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
54
|
+
return hit[1]
|
|
55
|
+
value = fn()
|
|
56
|
+
with _lock:
|
|
57
|
+
_cache[key] = (time.time(), value)
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cik_for(symbol: str) -> int:
|
|
62
|
+
table = _cached("tickers", lambda: {v["ticker"].upper(): int(v["cik_str"]) for v in _get(TICKERS_URL).json().values()})
|
|
63
|
+
cik = table.get(symbol.upper().replace(".", "-"))
|
|
64
|
+
if cik is None:
|
|
65
|
+
raise ValueError(f"{symbol} is not an SEC filer")
|
|
66
|
+
return cik
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def latest_filing(cik: int) -> dict:
|
|
70
|
+
recent = _get(SUBMISSIONS_URL.format(cik=cik)).json()["filings"]["recent"]
|
|
71
|
+
for i, form in enumerate(recent["form"]):
|
|
72
|
+
if form in ("10-K", "10-Q"):
|
|
73
|
+
return {"form": form, "filed": recent["filingDate"][i], "period": recent["reportDate"][i],
|
|
74
|
+
"accession": recent["accessionNumber"][i], "document": recent["primaryDocument"][i]}
|
|
75
|
+
raise ValueError("No 10-K or 10-Q on file")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def html_to_text(html: str) -> str:
|
|
79
|
+
from bs4 import BeautifulSoup
|
|
80
|
+
|
|
81
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
82
|
+
for tag in soup(["script", "style"]):
|
|
83
|
+
tag.decompose()
|
|
84
|
+
for hidden in soup.select('[style*="display:none"], [style*="display: none"]'):
|
|
85
|
+
hidden.decompose()
|
|
86
|
+
text = soup.get_text("\n")
|
|
87
|
+
text = re.sub(r"\xa0", " ", text)
|
|
88
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
89
|
+
return re.sub(r"\n\s*\n+", "\n\n", text).strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_START = {"10-K": r"item\s*7\.?\s*[-–—:]?\s*management[’'`s]*\s+discussion",
|
|
93
|
+
"10-Q": r"item\s*2\.?\s*[-–—:]?\s*management[’'`s]*\s+discussion"}
|
|
94
|
+
_END = {"10-K": r"item\s*7a\.?|item\s*8\.?\s*[-–—:]?\s*financial\s+statements",
|
|
95
|
+
"10-Q": r"item\s*3\.?\s*[-–—:]?\s*quantitative|item\s*4\.?\s*[-–—:]?\s*controls"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_mdna(text: str, form: str) -> str:
|
|
99
|
+
"""The MD&A section. The heading also appears in the table of contents, so
|
|
100
|
+
take the candidate that yields the longest section."""
|
|
101
|
+
best = ""
|
|
102
|
+
for m in re.finditer(_START[form], text, flags=re.I):
|
|
103
|
+
end = re.search(_END[form], text[m.end():], flags=re.I)
|
|
104
|
+
section = text[m.start(): m.end() + end.start()] if end else text[m.start(): m.start() + MAX_CHARS * 2]
|
|
105
|
+
if len(section) > len(best):
|
|
106
|
+
best = section
|
|
107
|
+
if len(best) < 1500:
|
|
108
|
+
best = _fallback(text)
|
|
109
|
+
if len(best) < 1500:
|
|
110
|
+
raise ValueError("Could not find the MD&A section in the filing")
|
|
111
|
+
return best.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _fallback(text: str) -> str:
|
|
115
|
+
"""Banks and some others open with MD&A without an 'Item' heading: take the
|
|
116
|
+
bare heading followed by the longest stretch before it repeats."""
|
|
117
|
+
hits = [m.start() for m in re.finditer(r"management[’'`s]*\s+discussion\s+and\s+analysis", text, flags=re.I)]
|
|
118
|
+
best = ""
|
|
119
|
+
for i, start in enumerate(hits):
|
|
120
|
+
stop = hits[i + 1] if i + 1 < len(hits) else len(text)
|
|
121
|
+
chunk = text[start:min(stop, start + MAX_CHARS * 2)]
|
|
122
|
+
if len(chunk) > len(best):
|
|
123
|
+
best = chunk
|
|
124
|
+
return best
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def mdna(symbol: str) -> dict:
|
|
128
|
+
cik = cik_for(symbol)
|
|
129
|
+
filing = latest_filing(cik)
|
|
130
|
+
|
|
131
|
+
def load():
|
|
132
|
+
acc = filing["accession"].replace("-", "")
|
|
133
|
+
html = _get(DOC_URL.format(cik=cik, acc=acc, doc=filing["document"])).text
|
|
134
|
+
return extract_mdna(html_to_text(html), filing["form"])
|
|
135
|
+
|
|
136
|
+
section = _cached(f"mdna:{filing['accession']}", load)
|
|
137
|
+
acc = filing["accession"].replace("-", "")
|
|
138
|
+
return {**filing, "cik": cik, "url": DOC_URL.format(cik=cik, acc=acc, doc=filing["document"]),
|
|
139
|
+
"chars": len(section), "text": section}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
SYSTEM = (
|
|
143
|
+
"You are an equity analyst summarising the Management's Discussion and Analysis section of an SEC filing "
|
|
144
|
+
"for a retail investor. Use ONLY the text given. Write Markdown with these sections: '### What happened' "
|
|
145
|
+
"(results vs the prior period, with the key numbers), '### Why' (the drivers management gives), "
|
|
146
|
+
"'### Outlook' (guidance, plans, capital spending, buybacks - say so if none is given), "
|
|
147
|
+
"'### Risks management flagged', '### Watch next' (2-3 bullets). Keep each section to 2-4 sentences or "
|
|
148
|
+
"bullets. Quote numbers exactly. No price targets, no buy/sell advice, no disclaimers."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def ai_summary(symbol: str, config) -> dict:
|
|
153
|
+
from ..llm.backend_factory import create_generation_backend
|
|
154
|
+
from ..llm.protocol import GenerationConfig
|
|
155
|
+
|
|
156
|
+
doc = mdna(symbol)
|
|
157
|
+
key = f"ai:{doc['accession']}"
|
|
158
|
+
with _lock:
|
|
159
|
+
hit = _cache.get(key)
|
|
160
|
+
if hit and time.time() - hit[0] < CACHE_SECONDS:
|
|
161
|
+
summary = hit[1]
|
|
162
|
+
else:
|
|
163
|
+
text = doc["text"][:MAX_CHARS]
|
|
164
|
+
prompt = (f"Company: {symbol}. Filing: {doc['form']} for the period ending {doc['period']}, filed {doc['filed']}."
|
|
165
|
+
f"{' (Section truncated.)' if doc['chars'] > MAX_CHARS else ''}\n\n{text}")
|
|
166
|
+
result = create_generation_backend(config).generate(
|
|
167
|
+
prompt, GenerationConfig(max_tokens=1100, timeout=110), system_prompt=SYSTEM)
|
|
168
|
+
summary = (result.text or "").strip()
|
|
169
|
+
if not summary:
|
|
170
|
+
raise RuntimeError("The AI model returned an empty summary")
|
|
171
|
+
with _lock:
|
|
172
|
+
_cache[key] = (time.time(), summary)
|
|
173
|
+
return {k: doc[k] for k in ("form", "filed", "period", "url", "chars")} | {"symbol": symbol, "summary": summary}
|