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,748 @@
|
|
|
1
|
+
"""Event calendar for the symbols people follow: upcoming earnings,
|
|
2
|
+
ex-dividend and dividend payment dates across every watchlist and portfolio,
|
|
3
|
+
plus a once-a-day reminder digest sent from the alert monitor loop.
|
|
4
|
+
|
|
5
|
+
Dates come from Yahoo's per-ticker calendar (the same source as the stock
|
|
6
|
+
overview's "upcoming events"); for Indian stocks the exchange's own board
|
|
7
|
+
meetings and corporate actions (services/nse_events.py) replace Yahoo's
|
|
8
|
+
estimates and add splits, bonus issues and the like. Market holidays and
|
|
9
|
+
sessions come from services/market_calendar.py. Modeled on OpenBB's
|
|
10
|
+
equity.calendar (Apache 2.0).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
19
|
+
from datetime import date, datetime, timedelta
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
from zoneinfo import ZoneInfo
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
KINDS = {"earnings": "Earnings", "ex_dividend": "Ex-dividend", "dividend": "Dividend payment"} # Yahoo's fields
|
|
26
|
+
ORDER = ["earnings", "board_meeting", "corporate_action", "ex_dividend", "dividend"] # same-day order
|
|
27
|
+
DEFAULTS = {"notify": True, "days_before": 1, "notify_hour": 8, "timezone": "Asia/Kolkata"}
|
|
28
|
+
CACHE_SECONDS = 6 * 3600
|
|
29
|
+
|
|
30
|
+
# Every event type a user can turn on / off: key, group, name, what it actually is.
|
|
31
|
+
EVENT_TYPES = [
|
|
32
|
+
("earnings", "stocks", "Earnings", "The company reports its quarterly results (revenue, profit, guidance). "
|
|
33
|
+
"The biggest single-day mover for a stock; the EPS estimate is what analysts expect."),
|
|
34
|
+
("ex_dividend", "stocks", "Ex-dividend date", "Buy before this date to get the next dividend. "
|
|
35
|
+
"The share price usually drops by about the dividend amount on this day."),
|
|
36
|
+
("dividend", "stocks", "Dividend payment", "The day the declared dividend is paid into shareholders' accounts."),
|
|
37
|
+
("board_meeting", "stocks", "Board meeting", "Indian stocks: the board meets on fund raising, a split, a bonus "
|
|
38
|
+
"or other business, as filed with the exchange. Results meetings show as Earnings."),
|
|
39
|
+
("corporate_action", "stocks", "Split, bonus, rights, buyback", "Indian stocks: the ex-date of a split, bonus "
|
|
40
|
+
"issue, rights issue or buyback. Buy before it to be entitled; the price adjusts on the day."),
|
|
41
|
+
("fed", "us", "Fed rate decision", "The US Federal Reserve sets its policy interest rate, 8 times a year. "
|
|
42
|
+
"Moves global markets, the dollar and foreign money flows into India."),
|
|
43
|
+
("us_cpi", "us", "US inflation (CPI)", "Monthly change in US consumer prices. The main number the Fed watches "
|
|
44
|
+
"before deciding rates."),
|
|
45
|
+
("us_pce", "us", "US PCE inflation", "Another US inflation measure, from consumer spending data. "
|
|
46
|
+
"The Fed's own preferred gauge."),
|
|
47
|
+
("us_jobs", "us", "US jobs report", "Jobs added and the unemployment rate, first Friday of the month. "
|
|
48
|
+
"Shows how strong the US economy is."),
|
|
49
|
+
("us_gdp", "us", "US GDP", "How fast the US economy grew last quarter (first estimate, then revisions)."),
|
|
50
|
+
("us_ppi", "us", "US producer prices (PPI)", "Prices businesses get at the wholesale level; an early hint of "
|
|
51
|
+
"where consumer inflation is heading."),
|
|
52
|
+
("us_retail", "us", "US retail sales", "Monthly change in what US shoppers spend in stores and online."),
|
|
53
|
+
("rbi", "india", "RBI rate decision", "The Reserve Bank of India's MPC sets the repo rate, about every "
|
|
54
|
+
"2 months. Drives loan and deposit rates and rate-sensitive stocks."),
|
|
55
|
+
("in_cpi", "india", "India inflation (CPI)", "Monthly retail inflation from MoSPI, around the 12th. "
|
|
56
|
+
"RBI aims to keep it between 2% and 6%."),
|
|
57
|
+
("in_gdp", "india", "India GDP", "How fast India's economy grew last quarter, from MoSPI at the end of "
|
|
58
|
+
"Feb / May / Aug / Nov."),
|
|
59
|
+
("market_holiday", "markets", "Market holidays", "Days the India, US, Hong Kong or China exchanges are shut, "
|
|
60
|
+
"with the stocks of yours that won't trade and when the market reopens."),
|
|
61
|
+
("short_day", "markets", "Short trading days", "US 1 pm closes (around Thanksgiving, Christmas, 4 July) and "
|
|
62
|
+
"Hong Kong half days on the eves of Christmas, New Year and Lunar New Year."),
|
|
63
|
+
("special_session", "markets", "Special sessions", "Muhurat trading on Diwali and Union Budget day, when "
|
|
64
|
+
"Indian markets open even on a weekend."),
|
|
65
|
+
("expiry", "markets", "F&O expiry", "Monthly expiry of Nifty, Sensex, Hang Seng and CSI 300 futures and "
|
|
66
|
+
"options, US options expiry and quarterly quad witching: heavy volume and late swings."),
|
|
67
|
+
("index_rebalance", "markets", "Index reviews", "MSCI's quarterly index changes take effect: big foreign "
|
|
68
|
+
"fund flows into or out of the stocks added or removed."),
|
|
69
|
+
("clock_change", "markets", "US clock changes", "US daylight saving starts or ends: the US session moves an "
|
|
70
|
+
"hour in India and the rest of Asia."),
|
|
71
|
+
("custom", "mine", "My own events", "Dates you add yourself: SIP day, IPO listing, AGM, results you are "
|
|
72
|
+
"waiting for, a bill due - one-off or repeating every month / year."),
|
|
73
|
+
]
|
|
74
|
+
REPEATS = ("none", "monthly", "yearly")
|
|
75
|
+
EVENT_KEYS = {k for k, *_ in EVENT_TYPES}
|
|
76
|
+
WORKERS = 8
|
|
77
|
+
|
|
78
|
+
Fetch = Callable[[str], dict]
|
|
79
|
+
|
|
80
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
81
|
+
_cache_lock = threading.Lock()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _as_date(value) -> Optional[date]:
|
|
85
|
+
if isinstance(value, (list, tuple)):
|
|
86
|
+
value = value[0] if value else None
|
|
87
|
+
if value is None:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(value, datetime):
|
|
90
|
+
return value.date()
|
|
91
|
+
if isinstance(value, date):
|
|
92
|
+
return value
|
|
93
|
+
try:
|
|
94
|
+
return date.fromisoformat(str(value)[:10])
|
|
95
|
+
except ValueError:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _yf_calendar(yf_symbol: str) -> dict:
|
|
100
|
+
"""{kind: date, eps_estimate, revenue_estimate} from Yahoo, cached."""
|
|
101
|
+
now = time.time()
|
|
102
|
+
with _cache_lock:
|
|
103
|
+
hit = _cache.get(yf_symbol)
|
|
104
|
+
if hit and now - hit[0] < CACHE_SECONDS:
|
|
105
|
+
return hit[1]
|
|
106
|
+
import yfinance as yf
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
cal = yf.Ticker(yf_symbol).calendar or {}
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
logger.info("Calendar for %s unavailable: %s", yf_symbol, exc)
|
|
112
|
+
cal = {}
|
|
113
|
+
out = {
|
|
114
|
+
"earnings": _as_date(cal.get("Earnings Date")),
|
|
115
|
+
"ex_dividend": _as_date(cal.get("Ex-Dividend Date")),
|
|
116
|
+
"dividend": _as_date(cal.get("Dividend Date")),
|
|
117
|
+
"eps_estimate": cal.get("Earnings Average"),
|
|
118
|
+
"revenue_estimate": cal.get("Revenue Average"),
|
|
119
|
+
}
|
|
120
|
+
with _cache_lock:
|
|
121
|
+
_cache[yf_symbol] = (now, out)
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def list_sources(config=None, catalog: Optional[dict] = None) -> list[dict]:
|
|
126
|
+
"""Every watchlist / portfolio a user can pick for events: [{id, kind, name, count}]."""
|
|
127
|
+
from .schedule_config import source_catalog
|
|
128
|
+
|
|
129
|
+
cat = catalog or source_catalog(config)
|
|
130
|
+
return ([{"id": f"p:{p['id']}", "kind": "portfolio", "name": p["name"], "count": len(p["symbols"])}
|
|
131
|
+
for p in cat["portfolios"]]
|
|
132
|
+
+ [{"id": f"w:{w['id']}", "kind": "watchlist", "name": w["name"], "count": len(w["symbols"])}
|
|
133
|
+
for w in cat["watchlists"]])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def clean_symbols(items) -> list[dict]:
|
|
137
|
+
"""[{symbol, market}] de-duplicated, upper-cased, crypto and blanks dropped."""
|
|
138
|
+
out, seen = [], set()
|
|
139
|
+
for i in items or []:
|
|
140
|
+
sym = str((i or {}).get("symbol", "")).strip().upper()[:40]
|
|
141
|
+
market = str((i or {}).get("market", "us")).strip().lower()[:32] or "us"
|
|
142
|
+
if sym and market != "crypto" and (sym, market) not in seen:
|
|
143
|
+
seen.add((sym, market))
|
|
144
|
+
out.append({"symbol": sym, "market": market})
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def followed_symbols(config=None, sources: Optional[list] = None, extra: Optional[list] = None,
|
|
149
|
+
catalog: Optional[dict] = None) -> dict[tuple[str, str], list[str]]:
|
|
150
|
+
"""(symbol, market) -> where it is followed ("Portfolio: X", "Watchlist: Y").
|
|
151
|
+
`sources` limits it to some lists ("w:<id>" / "p:<id>"; None = all of them),
|
|
152
|
+
`extra` adds stocks followed just for events."""
|
|
153
|
+
from .schedule_config import source_catalog
|
|
154
|
+
|
|
155
|
+
cat = catalog or source_catalog(config)
|
|
156
|
+
want = None if sources is None else set(sources)
|
|
157
|
+
out: dict[tuple[str, str], list[str]] = {}
|
|
158
|
+
for p in cat["portfolios"]:
|
|
159
|
+
if want is None or f"p:{p.get('id')}" in want:
|
|
160
|
+
for s in p["symbols"]:
|
|
161
|
+
out.setdefault((s["symbol"], s["market"]), []).append(f"Portfolio: {p['name']}")
|
|
162
|
+
for w in cat["watchlists"]:
|
|
163
|
+
if want is None or f"w:{w['id']}" in want:
|
|
164
|
+
for s in w["symbols"]:
|
|
165
|
+
out.setdefault((s["symbol"], s["market"]), []).append(f"Watchlist: {w['name']}")
|
|
166
|
+
for s in clean_symbols(extra):
|
|
167
|
+
out.setdefault((s["symbol"], s["market"]), []).append("Added for events")
|
|
168
|
+
return {k: v for k, v in out.items() if k[1] != "crypto"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def my_symbols(config, settings: Optional[dict] = None, catalog: Optional[dict] = None) -> dict[tuple[str, str], list[str]]:
|
|
172
|
+
"""The user's "My events list": their chosen lists plus extra stocks."""
|
|
173
|
+
settings = settings if settings is not None else load_settings(config)
|
|
174
|
+
return followed_symbols(config, settings.get("sources"), settings.get("extra_symbols"), catalog)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _nse() -> dict:
|
|
178
|
+
from .nse_events import india_events
|
|
179
|
+
|
|
180
|
+
return india_events()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def exchange_events(symbols: dict[tuple[str, str], list[str]], start: date, end: date, data: dict) -> list[dict]:
|
|
184
|
+
"""Board meetings and corporate actions from the exchange for the
|
|
185
|
+
followed Indian stocks: results meetings come as "earnings"."""
|
|
186
|
+
india = {s for s, m in symbols if m == "india"}
|
|
187
|
+
out = []
|
|
188
|
+
for r in data.get("meetings") or []:
|
|
189
|
+
d = date.fromisoformat(r["date"])
|
|
190
|
+
if r["symbol"] not in india or not (start <= d <= end):
|
|
191
|
+
continue
|
|
192
|
+
results = "results" in r["topics"]
|
|
193
|
+
out.append({"kind": "earnings" if results else "board_meeting",
|
|
194
|
+
"label": "Earnings" if results else "Board meeting", "symbol": r["symbol"], "date": d,
|
|
195
|
+
"detail": ("Board meeting: " + ", ".join(r["topics"])) if r["topics"] else "Board meeting",
|
|
196
|
+
"confirmed": True})
|
|
197
|
+
for r in data.get("actions") or []:
|
|
198
|
+
d = date.fromisoformat(r["date"])
|
|
199
|
+
if r["symbol"] not in india or not (start <= d <= end):
|
|
200
|
+
continue
|
|
201
|
+
dividend = r["label"] == "Dividend"
|
|
202
|
+
out.append({"kind": "ex_dividend" if dividend else "corporate_action",
|
|
203
|
+
"label": "Ex-dividend" if dividend else r["label"], "symbol": r["symbol"], "date": d,
|
|
204
|
+
"detail": r["subject"], "confirmed": True})
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def collect(symbols: dict[tuple[str, str], list[str]], start: date, days: int, fetch: Fetch = _yf_calendar,
|
|
209
|
+
exchange: Optional[Callable[[], dict]] = None) -> list[dict]:
|
|
210
|
+
"""Events dated start..start+days for the given symbols, soonest first.
|
|
211
|
+
`exchange` returns NSE's meetings / actions (nse_events.india_events);
|
|
212
|
+
by default it is used together with the live Yahoo fetch only."""
|
|
213
|
+
from .analysis_run import yahoo_symbol
|
|
214
|
+
|
|
215
|
+
if exchange is None and fetch is _yf_calendar:
|
|
216
|
+
exchange = _nse
|
|
217
|
+
|
|
218
|
+
end = start + timedelta(days=days)
|
|
219
|
+
keys = list(symbols)
|
|
220
|
+
|
|
221
|
+
def one(key):
|
|
222
|
+
try:
|
|
223
|
+
return key, fetch(yahoo_symbol(*key))
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.info("Calendar for %s failed: %s", key[0], exc)
|
|
226
|
+
return key, {}
|
|
227
|
+
|
|
228
|
+
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
|
229
|
+
results = list(pool.map(one, keys))
|
|
230
|
+
|
|
231
|
+
official = []
|
|
232
|
+
if exchange and any(m == "india" for _, m in keys):
|
|
233
|
+
try:
|
|
234
|
+
official = exchange_events(symbols, start, end, exchange())
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
logger.info("Exchange events unavailable: %s", exc)
|
|
237
|
+
# the exchange's date wins over Yahoo's for the same stock and kind
|
|
238
|
+
replaced = {(o["symbol"], o["kind"]) for o in official if o["kind"] in KINDS}
|
|
239
|
+
estimates = {symbol: cal for (symbol, market), cal in results if market == "india"}
|
|
240
|
+
|
|
241
|
+
events = []
|
|
242
|
+
for (symbol, market), cal in results:
|
|
243
|
+
for kind in KINDS:
|
|
244
|
+
when = cal.get(kind)
|
|
245
|
+
if when and start <= when <= end and not (market == "india" and (symbol, kind) in replaced):
|
|
246
|
+
event = {"symbol": symbol, "market": market, "kind": kind, "label": KINDS[kind],
|
|
247
|
+
"date": when.isoformat(), "days_away": (when - start).days,
|
|
248
|
+
"followed_in": symbols[(symbol, market)]}
|
|
249
|
+
if kind == "earnings":
|
|
250
|
+
event["eps_estimate"] = cal.get("eps_estimate")
|
|
251
|
+
event["revenue_estimate"] = cal.get("revenue_estimate")
|
|
252
|
+
events.append(event)
|
|
253
|
+
for o in official:
|
|
254
|
+
when = o.pop("date")
|
|
255
|
+
event = {**o, "market": "india", "date": when.isoformat(), "days_away": (when - start).days,
|
|
256
|
+
"followed_in": symbols[(o["symbol"], "india")]}
|
|
257
|
+
if o["kind"] == "earnings":
|
|
258
|
+
cal = estimates.get(o["symbol"]) or {}
|
|
259
|
+
event["eps_estimate"] = cal.get("eps_estimate")
|
|
260
|
+
event["revenue_estimate"] = cal.get("revenue_estimate")
|
|
261
|
+
events.append(event)
|
|
262
|
+
events.sort(key=lambda e: (e["date"], ORDER.index(e["kind"]), e["symbol"]))
|
|
263
|
+
return events
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------ settings
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _db(config):
|
|
270
|
+
from ..storage import DatabaseManager, get_db_url
|
|
271
|
+
|
|
272
|
+
return DatabaseManager.instance(get_db_url(config=config))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def load_settings(config) -> dict:
|
|
276
|
+
from ..storage import CalendarSettingsRecord
|
|
277
|
+
|
|
278
|
+
with _db(config).get_session() as s:
|
|
279
|
+
rec = s.query(CalendarSettingsRecord).order_by(CalendarSettingsRecord.id).first() # one row per user
|
|
280
|
+
if rec is None:
|
|
281
|
+
return {**DEFAULTS, "notified_day": None}
|
|
282
|
+
return {c.name: getattr(rec, c.name) for c in rec.__table__.columns if c.name not in ("id", "user_id")}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def hidden(settings: dict) -> set[str]:
|
|
286
|
+
"""Event type keys the user turned off."""
|
|
287
|
+
return {k for k in (settings.get("hidden_events") or "").split(",") if k in EVENT_KEYS}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def visible(items: list[dict], off: set[str], key: str) -> list[dict]:
|
|
291
|
+
return [e for e in items if e.get(key) not in off]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def save_settings(config, **changes) -> dict:
|
|
295
|
+
from ..storage import CalendarSettingsRecord
|
|
296
|
+
|
|
297
|
+
with _db(config).get_session() as s:
|
|
298
|
+
rec = s.query(CalendarSettingsRecord).order_by(CalendarSettingsRecord.id).first() # one row per user
|
|
299
|
+
if rec is None:
|
|
300
|
+
rec = CalendarSettingsRecord(**DEFAULTS)
|
|
301
|
+
s.add(rec)
|
|
302
|
+
for k, v in changes.items():
|
|
303
|
+
setattr(rec, k, v)
|
|
304
|
+
return load_settings(config)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------ custom events
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _add_months(d: date, months: int) -> date:
|
|
311
|
+
import calendar as pycal
|
|
312
|
+
|
|
313
|
+
m = d.year * 12 + d.month - 1 + months
|
|
314
|
+
y, mo = m // 12, m % 12 + 1
|
|
315
|
+
return date(y, mo, min(d.day, pycal.monthrange(y, mo)[1]))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def occurrences(rec: dict, start: date, days: int) -> list[date]:
|
|
319
|
+
"""Dates of a custom event inside start..start+days (repeats roll forward;
|
|
320
|
+
the 31st falls on the month's last day, 29 Feb on 28 Feb)."""
|
|
321
|
+
try:
|
|
322
|
+
first = date.fromisoformat(rec["date"])
|
|
323
|
+
except (TypeError, ValueError):
|
|
324
|
+
return []
|
|
325
|
+
end = start + timedelta(days=days)
|
|
326
|
+
step = {"monthly": 1, "yearly": 12}.get(rec.get("repeat") or "none")
|
|
327
|
+
if not step:
|
|
328
|
+
return [first] if start <= first <= end else []
|
|
329
|
+
out, n = [], 0
|
|
330
|
+
if first < start: # jump close to the window instead of walking from the first date
|
|
331
|
+
n = max(0, ((start.year - first.year) * 12 + start.month - first.month) // step - 1)
|
|
332
|
+
while (d := _add_months(first, n * step)) <= end:
|
|
333
|
+
if d >= start:
|
|
334
|
+
out.append(d)
|
|
335
|
+
n += 1
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def list_custom(config) -> list[dict]:
|
|
340
|
+
from ..storage import CustomEventRecord
|
|
341
|
+
|
|
342
|
+
with _db(config).get_session() as s:
|
|
343
|
+
return [{"id": r.id, "title": r.title, "date": r.date, "repeat": r.repeat or "none", "note": r.note or ""}
|
|
344
|
+
for r in s.query(CustomEventRecord).order_by(CustomEventRecord.date, CustomEventRecord.id)]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def save_custom(config, event_id: Optional[int] = None, **fields) -> Optional[dict]:
|
|
348
|
+
"""Create (no id) or update a custom event; None if that id is not the user's."""
|
|
349
|
+
from ..storage import CustomEventRecord
|
|
350
|
+
|
|
351
|
+
with _db(config).get_session() as s:
|
|
352
|
+
if event_id is None:
|
|
353
|
+
rec = CustomEventRecord(repeat="none")
|
|
354
|
+
s.add(rec)
|
|
355
|
+
else:
|
|
356
|
+
rec = s.query(CustomEventRecord).filter(CustomEventRecord.id == event_id).first()
|
|
357
|
+
if rec is None:
|
|
358
|
+
return None
|
|
359
|
+
for k in ("title", "date", "repeat", "note"):
|
|
360
|
+
if k in fields:
|
|
361
|
+
setattr(rec, k, fields[k])
|
|
362
|
+
s.flush()
|
|
363
|
+
return {"id": rec.id, "title": rec.title, "date": rec.date, "repeat": rec.repeat or "none", "note": rec.note or ""}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def delete_custom(config, event_id: int) -> bool:
|
|
367
|
+
from ..storage import CustomEventRecord
|
|
368
|
+
|
|
369
|
+
with _db(config).get_session() as s:
|
|
370
|
+
return bool(s.query(CustomEventRecord).filter(CustomEventRecord.id == event_id).delete())
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def custom_events(records: list[dict], start: date, days: int) -> list[dict]:
|
|
374
|
+
"""Custom events falling in the window, one row per occurrence, soonest first."""
|
|
375
|
+
out = []
|
|
376
|
+
for r in records:
|
|
377
|
+
for d in occurrences(r, start, days):
|
|
378
|
+
out.append({**r, "first_date": r["date"], "date": d.isoformat(), "days_away": (d - start).days,
|
|
379
|
+
"kind": "custom"})
|
|
380
|
+
out.sort(key=lambda e: (e["date"], e["title"]))
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ------------------------------------------------------------------ reminders
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _when(e: dict) -> str:
|
|
388
|
+
return "today" if e["days_away"] == 0 else "tomorrow" if e["days_away"] == 1 else e["date"]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def digest(events: list[dict], economic: Optional[list[dict]] = None, custom: Optional[list[dict]] = None) -> str:
|
|
392
|
+
lines = []
|
|
393
|
+
if custom:
|
|
394
|
+
lines.append("📌 Your events")
|
|
395
|
+
for e in custom[:20]:
|
|
396
|
+
note = f" - {e['note']}" if e.get("note") else ""
|
|
397
|
+
lines.append(f"• {e['title']}: {_when(e)}{note}")
|
|
398
|
+
if events:
|
|
399
|
+
if lines:
|
|
400
|
+
lines.append("")
|
|
401
|
+
lines.append(f"📅 {len(events)} upcoming event{'s' if len(events) != 1 else ''} for stocks you follow")
|
|
402
|
+
for e in events[:25]:
|
|
403
|
+
extra = f" (EPS est. {e['eps_estimate']:.2f})" if e.get("eps_estimate") is not None else ""
|
|
404
|
+
detail = f" - {e['detail']}" if e.get("detail") and e["kind"] != "earnings" else ""
|
|
405
|
+
lines.append(f"• {e['symbol']}: {e['label']} {_when(e)}{extra}{detail}")
|
|
406
|
+
if len(events) > 25:
|
|
407
|
+
lines.append(f"…and {len(events) - 25} more (open Watchlist → Upcoming events)")
|
|
408
|
+
markets = [e for e in economic or [] if e.get("kind") == "market"]
|
|
409
|
+
economic = [e for e in economic or [] if e.get("kind") != "market"]
|
|
410
|
+
if markets:
|
|
411
|
+
if lines:
|
|
412
|
+
lines.append("")
|
|
413
|
+
lines.append("🏛 Markets")
|
|
414
|
+
for e in markets[:15]:
|
|
415
|
+
at = f" {e['local_time']}" if e.get("local_time") else ""
|
|
416
|
+
lines.append(f"• {e['title']}: {_when(e)}{at}")
|
|
417
|
+
if e.get("note"):
|
|
418
|
+
lines.append(f" {e['note']}")
|
|
419
|
+
if e.get("exposure") and e.get("key") in ("market_holiday", "short_day"):
|
|
420
|
+
shown = ", ".join(e["exposure"][:6]) + (f" +{len(e['exposure']) - 6} more" if len(e["exposure"]) > 6 else "")
|
|
421
|
+
lines.append(f" Your stocks there: {shown}")
|
|
422
|
+
if economic:
|
|
423
|
+
if lines:
|
|
424
|
+
lines.append("")
|
|
425
|
+
lines.append("🌐 Economic calendar")
|
|
426
|
+
for e in economic[:10]:
|
|
427
|
+
at = f" {e['local_time']}" if e.get("local_time") else ""
|
|
428
|
+
lines.append(f"• {e['title']} ({e['region']}): {_when(e)}{at}")
|
|
429
|
+
if e.get("last"):
|
|
430
|
+
lines.append(f" Last: {e['last']}")
|
|
431
|
+
if e.get("why"):
|
|
432
|
+
lines.append(f" Why it matters: {e['why']}")
|
|
433
|
+
if e.get("exposure"):
|
|
434
|
+
shown = ", ".join(e["exposure"][:4]) + (f" +{len(e['exposure']) - 4} more" if len(e["exposure"]) > 4 else "")
|
|
435
|
+
lines.append(f" Your {e['region']} stocks: {shown}")
|
|
436
|
+
return "\n".join(lines)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _economic(start: date, days: int, tz: str, followed: Optional[dict] = None,
|
|
440
|
+
off: frozenset = frozenset()) -> list[dict]:
|
|
441
|
+
"""Macro and market events (holidays, sessions, expiries) for the reminder
|
|
442
|
+
window that the user has on, with their last reading and why they matter;
|
|
443
|
+
never raises."""
|
|
444
|
+
try:
|
|
445
|
+
from . import econ_calendar
|
|
446
|
+
|
|
447
|
+
events = econ_calendar.collect(start, days)["events"] + _markets(start, days)
|
|
448
|
+
events.sort(key=lambda e: (e["date"], e["time"] or "", e["title"]))
|
|
449
|
+
for e in events:
|
|
450
|
+
e["local_time"] = econ_calendar.local_time(e, tz)
|
|
451
|
+
return visible(econ_calendar.enrich(events, followed), off, "key")
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
logger.info("Economic calendar for reminders failed: %s", exc)
|
|
454
|
+
return []
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _markets(start: date, days: int) -> list[dict]:
|
|
458
|
+
try:
|
|
459
|
+
from . import market_calendar
|
|
460
|
+
|
|
461
|
+
return market_calendar.collect(start, days)["events"]
|
|
462
|
+
except Exception as exc:
|
|
463
|
+
logger.info("Market calendar failed: %s", exc)
|
|
464
|
+
return []
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _tz(settings: dict) -> ZoneInfo:
|
|
468
|
+
try:
|
|
469
|
+
return ZoneInfo(settings.get("timezone") or DEFAULTS["timezone"])
|
|
470
|
+
except Exception:
|
|
471
|
+
return ZoneInfo(DEFAULTS["timezone"])
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _local(now: Optional[datetime], tz: ZoneInfo) -> datetime:
|
|
475
|
+
if now is None:
|
|
476
|
+
return datetime.now(tz)
|
|
477
|
+
# a naive `now` is read as already in the settings' timezone
|
|
478
|
+
return now.astimezone(tz) if now.tzinfo else now.replace(tzinfo=tz)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def gather(config, day: date, window: int, followed: dict, off: set, tz: str, fetch: Fetch = _yf_calendar,
|
|
482
|
+
economic_fetch: Optional[Callable] = _economic, custom_fetch: Optional[Callable] = list_custom) -> dict:
|
|
483
|
+
"""Everything due day..day+window for these stocks, minus the event types in `off`."""
|
|
484
|
+
events = visible(collect(followed, day, window, fetch), off, "kind") if followed else []
|
|
485
|
+
economic = economic_fetch(day, window, tz, followed, frozenset(off)) if economic_fetch else []
|
|
486
|
+
custom = custom_events(custom_fetch(config), day, window) if custom_fetch and "custom" not in off else []
|
|
487
|
+
return {"events": events, "economic": economic, "custom": custom}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _send(config, dispatcher, found: dict, title: str) -> dict:
|
|
491
|
+
if not (found["events"] or found["economic"] or found["custom"]):
|
|
492
|
+
return {}
|
|
493
|
+
try:
|
|
494
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
495
|
+
|
|
496
|
+
dispatcher = dispatcher or NotificationDispatcher(config)
|
|
497
|
+
return dispatcher.send_all(digest(found["events"], found["economic"], found["custom"]),
|
|
498
|
+
title=title, purpose="alert")
|
|
499
|
+
except Exception as exc:
|
|
500
|
+
logger.error("%s failed: %s", title, exc)
|
|
501
|
+
return {}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def check_reminders(config, now: Optional[datetime] = None, dispatcher=None, fetch: Fetch = _yf_calendar,
|
|
505
|
+
force: bool = False, economic_fetch: Optional[Callable] = _economic,
|
|
506
|
+
custom_fetch: Optional[Callable] = list_custom) -> Optional[dict]:
|
|
507
|
+
"""Monitor hook: once a day, after `notify_hour`, send one digest of events
|
|
508
|
+
due within `days_before` days. `force` sends now (the "Send test" button)."""
|
|
509
|
+
settings = load_settings(config)
|
|
510
|
+
if not settings.get("notify") and not force:
|
|
511
|
+
return None
|
|
512
|
+
tz = _tz(settings)
|
|
513
|
+
local = _local(now, tz)
|
|
514
|
+
today = local.strftime("%Y-%m-%d")
|
|
515
|
+
if not force and (settings.get("notified_day") == today or local.hour < int(settings.get("notify_hour") or 0)):
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
window = int(settings.get("days_before") or 1)
|
|
519
|
+
found = gather(config, local.date(), window, my_symbols(config, settings), hidden(settings), str(tz),
|
|
520
|
+
fetch, economic_fetch, custom_fetch)
|
|
521
|
+
delivery = _send(config, dispatcher, found, "Upcoming events")
|
|
522
|
+
if not force:
|
|
523
|
+
save_settings(config, notified_day=today)
|
|
524
|
+
return {**found, "delivery": delivery}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ------------------------------------------------------------------ named event alerts
|
|
528
|
+
|
|
529
|
+
ALERT_FIELDS = ("name", "enabled", "sources", "symbols", "event_types", "days_before", "notify_hour")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _alert_dict(r) -> dict:
|
|
533
|
+
return {"id": r.id, "name": r.name, "enabled": bool(r.enabled), "sources": r.sources,
|
|
534
|
+
"symbols": r.symbols or [], "event_types": r.event_types, "days_before": r.days_before or 0,
|
|
535
|
+
"notify_hour": r.notify_hour if r.notify_hour is not None else 8, "notified_day": r.notified_day}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def list_alerts(config) -> list[dict]:
|
|
539
|
+
from ..storage import EventAlertRecord
|
|
540
|
+
|
|
541
|
+
with _db(config).get_session() as s:
|
|
542
|
+
return [_alert_dict(r) for r in s.query(EventAlertRecord).order_by(EventAlertRecord.id)]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def save_alert(config, alert_id: Optional[int] = None, **changes) -> Optional[dict]:
|
|
546
|
+
"""Create (no id) or update an alert; None if that id is not the user's."""
|
|
547
|
+
from ..storage import EventAlertRecord
|
|
548
|
+
|
|
549
|
+
if "symbols" in changes:
|
|
550
|
+
changes["symbols"] = clean_symbols(changes["symbols"])
|
|
551
|
+
if changes.get("event_types") is not None:
|
|
552
|
+
changes["event_types"] = sorted(set(changes["event_types"]) & EVENT_KEYS)
|
|
553
|
+
with _db(config).get_session() as s:
|
|
554
|
+
if alert_id is None:
|
|
555
|
+
rec = EventAlertRecord(name="Event alert", enabled=True, days_before=1, notify_hour=8)
|
|
556
|
+
s.add(rec)
|
|
557
|
+
else:
|
|
558
|
+
rec = s.query(EventAlertRecord).filter(EventAlertRecord.id == alert_id).first()
|
|
559
|
+
if rec is None:
|
|
560
|
+
return None
|
|
561
|
+
for k, v in changes.items():
|
|
562
|
+
if k in ALERT_FIELDS or k == "notified_day":
|
|
563
|
+
setattr(rec, k, v)
|
|
564
|
+
if {"days_before", "notify_hour", "event_types", "sources", "symbols"} & set(changes) and "notified_day" not in changes:
|
|
565
|
+
rec.notified_day = None # a changed alert may go out again today
|
|
566
|
+
s.flush()
|
|
567
|
+
return _alert_dict(rec)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def delete_alert(config, alert_id: int) -> bool:
|
|
571
|
+
from ..storage import EventAlertRecord
|
|
572
|
+
|
|
573
|
+
with _db(config).get_session() as s:
|
|
574
|
+
return bool(s.query(EventAlertRecord).filter(EventAlertRecord.id == alert_id).delete())
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def alert_off(alert: dict) -> set:
|
|
578
|
+
types = alert.get("event_types")
|
|
579
|
+
return set() if types is None else EVENT_KEYS - set(types)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def run_alert(config, alert: dict, local: datetime, dispatcher=None, fetch: Fetch = _yf_calendar,
|
|
583
|
+
economic_fetch: Optional[Callable] = _economic, custom_fetch: Optional[Callable] = list_custom) -> dict:
|
|
584
|
+
followed = followed_symbols(config, alert.get("sources"), alert.get("symbols"))
|
|
585
|
+
found = gather(config, local.date(), int(alert.get("days_before") or 0), followed, alert_off(alert),
|
|
586
|
+
str(local.tzinfo), fetch, economic_fetch, custom_fetch)
|
|
587
|
+
return {**found, "delivery": _send(config, dispatcher, found, alert.get("name") or "Event alert")}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def check_event_alerts(config, now: Optional[datetime] = None, dispatcher=None, fetch: Fetch = _yf_calendar,
|
|
591
|
+
economic_fetch: Optional[Callable] = _economic,
|
|
592
|
+
custom_fetch: Optional[Callable] = list_custom) -> list[str]:
|
|
593
|
+
"""Monitor hook: each enabled alert goes out once a day after its hour; returns the names sent."""
|
|
594
|
+
alerts = [a for a in list_alerts(config) if a["enabled"]]
|
|
595
|
+
if not alerts:
|
|
596
|
+
return []
|
|
597
|
+
local = _local(now, _tz(load_settings(config)))
|
|
598
|
+
today, ran = local.strftime("%Y-%m-%d"), []
|
|
599
|
+
for a in alerts:
|
|
600
|
+
if a["notified_day"] == today or local.hour < a["notify_hour"]:
|
|
601
|
+
continue
|
|
602
|
+
try:
|
|
603
|
+
run_alert(config, a, local, dispatcher, fetch, economic_fetch, custom_fetch)
|
|
604
|
+
except Exception as exc:
|
|
605
|
+
logger.error("Event alert %s failed: %s", a["name"], exc)
|
|
606
|
+
save_alert(config, a["id"], notified_day=today)
|
|
607
|
+
ran.append(a["name"])
|
|
608
|
+
return ran
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def result_message(done: list[dict]) -> str:
|
|
612
|
+
lines = []
|
|
613
|
+
for e in done:
|
|
614
|
+
lines.append(f"📊 {e['title']} is out: {e['result']}")
|
|
615
|
+
if e.get("verdict"):
|
|
616
|
+
lines.append(f" {e['verdict']}")
|
|
617
|
+
if e.get("exposure"):
|
|
618
|
+
shown = ", ".join(e["exposure"][:4]) + (f" +{len(e['exposure']) - 4} more" if len(e["exposure"]) > 4 else "")
|
|
619
|
+
lines.append(f" Your {e['region']} stocks: {shown}")
|
|
620
|
+
return "\n".join(lines)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _today_economic(day: date) -> list[dict]:
|
|
624
|
+
from . import econ_calendar
|
|
625
|
+
|
|
626
|
+
return econ_calendar.collect(day, 0)["events"]
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _fresh_points() -> dict:
|
|
630
|
+
from . import econ_calendar
|
|
631
|
+
|
|
632
|
+
return econ_calendar.points(max_age=15 * 60) # shared by every user, re-read at most every 15 min
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def check_results(config, now: Optional[datetime] = None, dispatcher=None,
|
|
636
|
+
today_events: Callable[[date], list[dict]] = _today_economic,
|
|
637
|
+
fetch_points: Callable[[], dict] = _fresh_points) -> Optional[list[dict]]:
|
|
638
|
+
"""Monitor hook: once a US data release the user has on is out, send its
|
|
639
|
+
actual number against the one before - one message per release."""
|
|
640
|
+
settings = load_settings(config)
|
|
641
|
+
if not settings.get("notify"):
|
|
642
|
+
return None
|
|
643
|
+
local = _local(now, _tz(settings))
|
|
644
|
+
today = local.strftime("%Y-%m-%d")
|
|
645
|
+
sent = _sent_keys(settings, local.date())
|
|
646
|
+
off = hidden(settings)
|
|
647
|
+
|
|
648
|
+
from . import econ_calendar
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
events = [e for e in today_events(local.date()) if (e.get("key") or "") not in off | sent]
|
|
652
|
+
done = econ_calendar.results(events, local, fetch_points)
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
logger.info("Release results check failed: %s", exc)
|
|
655
|
+
return None
|
|
656
|
+
done = [e for e in done if e["key"] not in off | sent]
|
|
657
|
+
if not done:
|
|
658
|
+
return None
|
|
659
|
+
econ_calendar.enrich(done, my_symbols(config, settings), read=dict)
|
|
660
|
+
try:
|
|
661
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
662
|
+
|
|
663
|
+
dispatcher = dispatcher or NotificationDispatcher(config)
|
|
664
|
+
dispatcher.send_all(result_message(done), title="Economic data is out", purpose="alert")
|
|
665
|
+
except Exception as exc:
|
|
666
|
+
logger.error("Release result message failed: %s", exc)
|
|
667
|
+
save_settings(config, results_sent=f"{today}:{','.join(sorted(sent | {e['key'] for e in done}))}")
|
|
668
|
+
return done
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ------------------------------------------------------------------ company results
|
|
672
|
+
|
|
673
|
+
STOCK_KEEP_DAYS = 7 # "e:SYMBOL:date" sent-markers outlive the day; economic ones don't
|
|
674
|
+
EARNINGS_RECHECK = 30 * 60 # look for a fresh actual EPS at most this often per stock
|
|
675
|
+
_earnings_checked: dict[str, float] = {}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _sent_keys(settings: dict, today: date) -> set[str]:
|
|
679
|
+
"""Release / results markers already sent: today's economic keys plus
|
|
680
|
+
company results from the last STOCK_KEEP_DAYS."""
|
|
681
|
+
day, _, raw = (settings.get("results_sent") or "").partition(":")
|
|
682
|
+
keys = set(raw.split(",")) - {""}
|
|
683
|
+
keep = set()
|
|
684
|
+
for k in keys:
|
|
685
|
+
if k.startswith("e:"):
|
|
686
|
+
try:
|
|
687
|
+
if (today - date.fromisoformat(k.rsplit(":", 1)[1])).days <= STOCK_KEEP_DAYS:
|
|
688
|
+
keep.add(k)
|
|
689
|
+
except ValueError:
|
|
690
|
+
continue
|
|
691
|
+
elif day == today.isoformat():
|
|
692
|
+
keep.add(k)
|
|
693
|
+
return keep
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _recent_reports(config, followed: dict, day: date, fetch: Fetch = _yf_calendar) -> list[tuple[str, str]]:
|
|
697
|
+
"""Followed stocks whose earnings date was in the last two days."""
|
|
698
|
+
return [(e["symbol"], e["market"]) for e in collect(followed, day - timedelta(days=2), 2, fetch)
|
|
699
|
+
if e["kind"] == "earnings" and e["days_away"] <= 2]
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def check_earnings_results(config, now: Optional[datetime] = None, dispatcher=None,
|
|
703
|
+
reports: Optional[Callable] = None, build: Optional[Callable] = None) -> Optional[list[str]]:
|
|
704
|
+
"""Monitor hook: once a stock in the user's events list has reported and
|
|
705
|
+
Yahoo has the actual EPS, send actual vs expected and the price move - once."""
|
|
706
|
+
settings = load_settings(config)
|
|
707
|
+
off = hidden(settings)
|
|
708
|
+
if not settings.get("notify") or "earnings" in off:
|
|
709
|
+
return None
|
|
710
|
+
local = _local(now, _tz(settings))
|
|
711
|
+
sent = _sent_keys(settings, local.date())
|
|
712
|
+
try:
|
|
713
|
+
candidates = (reports or _recent_reports)(config, my_symbols(config, settings), local.date())
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
logger.info("Earnings results check failed: %s", exc)
|
|
716
|
+
return None
|
|
717
|
+
if build is None:
|
|
718
|
+
from .earnings import build
|
|
719
|
+
from .earnings import results_line
|
|
720
|
+
|
|
721
|
+
lines, new_keys = [], set()
|
|
722
|
+
clock = time.time()
|
|
723
|
+
for symbol, market in candidates:
|
|
724
|
+
if any(k.startswith(f"e:{symbol}:") for k in sent):
|
|
725
|
+
continue
|
|
726
|
+
if clock - _earnings_checked.get(f"{symbol}:{market}", 0) < EARNINGS_RECHECK:
|
|
727
|
+
continue
|
|
728
|
+
_earnings_checked[f"{symbol}:{market}"] = clock
|
|
729
|
+
try:
|
|
730
|
+
data = build(symbol, market, with_options=False, use_cache=False)
|
|
731
|
+
except Exception as exc:
|
|
732
|
+
logger.info("Earnings for %s unavailable: %s", symbol, exc)
|
|
733
|
+
continue
|
|
734
|
+
line = results_line(symbol, data)
|
|
735
|
+
if line:
|
|
736
|
+
lines.append(f"• {line}")
|
|
737
|
+
new_keys.add(f"e:{symbol}:{data['latest']['date']}")
|
|
738
|
+
if not lines:
|
|
739
|
+
return None
|
|
740
|
+
try:
|
|
741
|
+
from ..notifiers.dispatcher import NotificationDispatcher
|
|
742
|
+
|
|
743
|
+
dispatcher = dispatcher or NotificationDispatcher(config)
|
|
744
|
+
dispatcher.send_all("📊 Results are out\n" + "\n".join(lines), title="Results are out", purpose="alert")
|
|
745
|
+
except Exception as exc:
|
|
746
|
+
logger.error("Earnings results message failed: %s", exc)
|
|
747
|
+
save_settings(config, results_sent=f"{local.date().isoformat()}:{','.join(sorted(sent | new_keys))}")
|
|
748
|
+
return lines
|