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,34 @@
|
|
|
1
|
+
"""Mutual Fund data fetcher for Indian mutual funds."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MutualFundDataFetcher:
|
|
8
|
+
"""Fetches mutual fund data from MFAPI.in (free API for Indian MFs)."""
|
|
9
|
+
|
|
10
|
+
BASE_URL = "https://api.mfapi.in"
|
|
11
|
+
|
|
12
|
+
def get_historical_nav(self, scheme_code: int, days: int = 365) -> pd.DataFrame:
|
|
13
|
+
"""Get historical NAV data for a scheme."""
|
|
14
|
+
try:
|
|
15
|
+
response = requests.get(f"{self.BASE_URL}/mf/{scheme_code}", timeout=10)
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
data = response.json()
|
|
18
|
+
|
|
19
|
+
nav_data = data.get("data", [])[:days]
|
|
20
|
+
|
|
21
|
+
df = pd.DataFrame(nav_data)
|
|
22
|
+
if not df.empty:
|
|
23
|
+
df["date"] = pd.to_datetime(df["date"], format="%d-%m-%Y")
|
|
24
|
+
df["nav"] = df["nav"].astype(float)
|
|
25
|
+
df = df.sort_values("date")
|
|
26
|
+
df.set_index("date", inplace=True)
|
|
27
|
+
|
|
28
|
+
return df
|
|
29
|
+
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f"Error fetching historical NAV for {scheme_code}: {e}")
|
|
32
|
+
return pd.DataFrame()
|
|
33
|
+
|
|
34
|
+
|
marketmyna/data/news.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""News data fetcher for stocks."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class NewsArticle:
|
|
10
|
+
"""Represents a news article."""
|
|
11
|
+
title: str
|
|
12
|
+
summary: str
|
|
13
|
+
source: str
|
|
14
|
+
url: str
|
|
15
|
+
published: datetime
|
|
16
|
+
sentiment: Optional[str] = None # positive, negative, neutral
|
|
17
|
+
symbols: list[str] = None
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"title": self.title,
|
|
22
|
+
"summary": self.summary,
|
|
23
|
+
"source": self.source,
|
|
24
|
+
"url": self.url,
|
|
25
|
+
"published": self.published.isoformat() if self.published else None,
|
|
26
|
+
"sentiment": self.sentiment,
|
|
27
|
+
"symbols": self.symbols or [],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NewsFetcher:
|
|
32
|
+
"""Fetches a stock's news from Yahoo Finance."""
|
|
33
|
+
|
|
34
|
+
def get_stock_news(self, symbol: str, limit: int = 10) -> list[NewsArticle]:
|
|
35
|
+
"""Get news for a specific stock using yfinance."""
|
|
36
|
+
try:
|
|
37
|
+
import yfinance as yf
|
|
38
|
+
|
|
39
|
+
ticker = yf.Ticker(symbol)
|
|
40
|
+
news = ticker.news
|
|
41
|
+
|
|
42
|
+
articles = []
|
|
43
|
+
for item in news[:limit]:
|
|
44
|
+
# Handle both old and new yfinance API formats
|
|
45
|
+
content = item.get("content", item) # New API nests data in 'content'
|
|
46
|
+
|
|
47
|
+
# Extract title
|
|
48
|
+
title = content.get("title", item.get("title", ""))
|
|
49
|
+
|
|
50
|
+
# Extract summary
|
|
51
|
+
summary = content.get("summary", item.get("summary", title))
|
|
52
|
+
|
|
53
|
+
# Extract source/publisher - try multiple locations
|
|
54
|
+
source = "Unknown"
|
|
55
|
+
if "provider" in content and isinstance(content["provider"], dict):
|
|
56
|
+
source = content["provider"].get("displayName", "Unknown")
|
|
57
|
+
elif "publisher" in item:
|
|
58
|
+
source = item["publisher"]
|
|
59
|
+
elif "publisher" in content:
|
|
60
|
+
source = content["publisher"]
|
|
61
|
+
|
|
62
|
+
# Extract URL - try multiple locations
|
|
63
|
+
url = ""
|
|
64
|
+
if "canonicalUrl" in content and isinstance(content["canonicalUrl"], dict):
|
|
65
|
+
url = content["canonicalUrl"].get("url", "")
|
|
66
|
+
elif "clickThroughUrl" in content and isinstance(content["clickThroughUrl"], dict):
|
|
67
|
+
url = content["clickThroughUrl"].get("url", "")
|
|
68
|
+
elif "link" in item:
|
|
69
|
+
url = item["link"]
|
|
70
|
+
|
|
71
|
+
# Extract published date
|
|
72
|
+
try:
|
|
73
|
+
if "pubDate" in content:
|
|
74
|
+
# ISO format: "2026-01-05T12:55:00Z"
|
|
75
|
+
pub_str = content["pubDate"]
|
|
76
|
+
published = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
|
|
77
|
+
elif "providerPublishTime" in item:
|
|
78
|
+
published = datetime.fromtimestamp(item["providerPublishTime"])
|
|
79
|
+
else:
|
|
80
|
+
published = datetime.now()
|
|
81
|
+
except:
|
|
82
|
+
published = datetime.now()
|
|
83
|
+
|
|
84
|
+
article = NewsArticle(
|
|
85
|
+
title=title,
|
|
86
|
+
summary=summary,
|
|
87
|
+
source=source,
|
|
88
|
+
url=url,
|
|
89
|
+
published=published,
|
|
90
|
+
symbols=[symbol],
|
|
91
|
+
)
|
|
92
|
+
articles.append(article)
|
|
93
|
+
|
|
94
|
+
return articles
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f"Error fetching news for {symbol}: {e}")
|
|
97
|
+
return []
|
|
98
|
+
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Stock news search across several providers, for the AI and the report.
|
|
2
|
+
|
|
3
|
+
Same environment variables as daily_stock_analysis, so one set of
|
|
4
|
+
secrets works for both projects:
|
|
5
|
+
|
|
6
|
+
TAVILY_API_KEYS comma-separated, rotated (tavily.com)
|
|
7
|
+
BRAVE_API_KEYS comma-separated, rotated (brave.com/search/api)
|
|
8
|
+
SERPAPI_API_KEYS comma-separated, rotated (serpapi.com, Google News)
|
|
9
|
+
BOCHA_API_KEYS comma-separated, rotated (bochaai.com, Chinese web)
|
|
10
|
+
SEARXNG_BASE_URLS your own SearXNG instance(s), no key
|
|
11
|
+
NEWS_MAX_AGE_DAYS how far back to look (default 3)
|
|
12
|
+
|
|
13
|
+
With none of those set it still works: Google News RSS (free, no key)
|
|
14
|
+
and then Yahoo Finance headlines are always available as fallbacks.
|
|
15
|
+
Providers are tried in the order above; the first one that returns
|
|
16
|
+
results wins, and a failing key is skipped on the next call.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import xml.etree.ElementTree as ET
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from datetime import datetime, timedelta, timezone
|
|
29
|
+
from email.utils import parsedate_to_datetime
|
|
30
|
+
from functools import lru_cache
|
|
31
|
+
from typing import Callable, Mapping, Optional
|
|
32
|
+
from urllib.parse import quote_plus
|
|
33
|
+
|
|
34
|
+
import requests
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
_UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"}
|
|
39
|
+
_TIMEOUT = 15
|
|
40
|
+
_CACHE_SECONDS = 30 * 60
|
|
41
|
+
|
|
42
|
+
# Titles that are quote pages, not news.
|
|
43
|
+
_JUNK = re.compile(r"stock price, news, quote|share price today|live share price|stock quote|historical prices", re.I)
|
|
44
|
+
|
|
45
|
+
# Google News locale per market: (hl, gl, ceid)
|
|
46
|
+
_LOCALES = {
|
|
47
|
+
"india": ("en-IN", "IN", "IN:en"),
|
|
48
|
+
"us": ("en-US", "US", "US:en"),
|
|
49
|
+
"hong_kong": ("en-HK", "HK", "HK:en"),
|
|
50
|
+
"china_a": ("en-US", "US", "US:en"), # English coverage of A-shares is far better via Google News
|
|
51
|
+
"crypto": ("en-US", "US", "US:en"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class NewsItem:
|
|
57
|
+
title: str
|
|
58
|
+
source: str = ""
|
|
59
|
+
url: str = ""
|
|
60
|
+
snippet: str = ""
|
|
61
|
+
published: Optional[str] = None # ISO 8601
|
|
62
|
+
provider: str = ""
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict:
|
|
65
|
+
return asdict(self)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _split(raw: Optional[str]) -> list[str]:
|
|
69
|
+
return [x.strip() for x in (raw or "").split(",") if x.strip()]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _iso(value) -> Optional[str]:
|
|
73
|
+
if not value:
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
if isinstance(value, (int, float)):
|
|
77
|
+
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
|
78
|
+
text = str(value).strip()
|
|
79
|
+
try:
|
|
80
|
+
return parsedate_to_datetime(text).isoformat()
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")).isoformat()
|
|
83
|
+
except (TypeError, ValueError, OverflowError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fresh(item: NewsItem, days: int) -> bool:
|
|
88
|
+
if not item.published:
|
|
89
|
+
return True # unknown age: keep, providers already filter by time
|
|
90
|
+
try:
|
|
91
|
+
published = datetime.fromisoformat(item.published)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return True
|
|
94
|
+
if published.tzinfo is None:
|
|
95
|
+
published = published.replace(tzinfo=timezone.utc)
|
|
96
|
+
return published >= datetime.now(timezone.utc) - timedelta(days=days + 1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _freshness(days: int, day: str, week: str, month: str, year: str) -> str:
|
|
100
|
+
return day if days <= 1 else week if days <= 7 else month if days <= 31 else year
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------- providers
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _tavily(query: str, key: str, limit: int, days: int, _market: str) -> list[NewsItem]:
|
|
107
|
+
resp = requests.post(
|
|
108
|
+
"https://api.tavily.com/search",
|
|
109
|
+
json={"query": query, "topic": "news", "days": days, "max_results": limit, "api_key": key},
|
|
110
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
111
|
+
timeout=_TIMEOUT,
|
|
112
|
+
)
|
|
113
|
+
resp.raise_for_status()
|
|
114
|
+
return [
|
|
115
|
+
NewsItem(r.get("title", ""), source=re.sub(r"^www\.", "", (r.get("url") or "").split("/")[2] if r.get("url") else ""),
|
|
116
|
+
url=r.get("url", ""), snippet=r.get("content", ""), published=_iso(r.get("published_date")))
|
|
117
|
+
for r in resp.json().get("results", [])
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _brave(query: str, key: str, limit: int, days: int, _market: str) -> list[NewsItem]:
|
|
122
|
+
resp = requests.get(
|
|
123
|
+
"https://api.search.brave.com/res/v1/news/search",
|
|
124
|
+
params={"q": query, "count": limit, "freshness": _freshness(days, "pd", "pw", "pm", "py")},
|
|
125
|
+
headers={"X-Subscription-Token": key, "Accept": "application/json"},
|
|
126
|
+
timeout=_TIMEOUT,
|
|
127
|
+
)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
return [
|
|
130
|
+
NewsItem(r.get("title", ""), source=(r.get("meta_url") or {}).get("hostname", ""), url=r.get("url", ""),
|
|
131
|
+
snippet=r.get("description", ""), published=_iso(r.get("page_age")))
|
|
132
|
+
for r in resp.json().get("results", [])
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _serpapi(query: str, key: str, limit: int, days: int, market: str) -> list[NewsItem]:
|
|
137
|
+
hl, gl, _ = _LOCALES.get(market, _LOCALES["us"])
|
|
138
|
+
resp = requests.get(
|
|
139
|
+
"https://serpapi.com/search.json",
|
|
140
|
+
params={"engine": "google_news", "q": f"{query} when:{max(1, days)}d", "api_key": key,
|
|
141
|
+
"hl": hl.split("-")[0], "gl": gl.lower()},
|
|
142
|
+
timeout=_TIMEOUT,
|
|
143
|
+
)
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
items = []
|
|
146
|
+
for r in resp.json().get("news_results", [])[:limit]:
|
|
147
|
+
source = r.get("source") or {}
|
|
148
|
+
items.append(NewsItem(r.get("title", ""), source=source.get("name", "") if isinstance(source, dict) else str(source),
|
|
149
|
+
url=r.get("link", ""), snippet=r.get("snippet", ""), published=_iso(r.get("iso_date") or r.get("date"))))
|
|
150
|
+
return items
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _bocha(query: str, key: str, limit: int, days: int, _market: str) -> list[NewsItem]:
|
|
154
|
+
resp = requests.post(
|
|
155
|
+
"https://api.bochaai.com/v1/web-search",
|
|
156
|
+
json={"query": query, "count": limit, "summary": True,
|
|
157
|
+
"freshness": _freshness(days, "oneDay", "oneWeek", "oneMonth", "oneYear")},
|
|
158
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
159
|
+
timeout=_TIMEOUT,
|
|
160
|
+
)
|
|
161
|
+
resp.raise_for_status()
|
|
162
|
+
pages = ((resp.json().get("data") or {}).get("webPages") or {}).get("value", [])
|
|
163
|
+
return [
|
|
164
|
+
NewsItem(p.get("name", ""), source=p.get("siteName", ""), url=p.get("url", ""),
|
|
165
|
+
snippet=p.get("summary") or p.get("snippet", ""), published=_iso(p.get("datePublished")))
|
|
166
|
+
for p in pages
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _searxng(query: str, base_url: str, limit: int, days: int, _market: str) -> list[NewsItem]:
|
|
171
|
+
resp = requests.get(
|
|
172
|
+
f"{base_url.rstrip('/')}/search",
|
|
173
|
+
params={"q": query, "format": "json", "categories": "news",
|
|
174
|
+
"time_range": _freshness(days, "day", "week", "month", "year")},
|
|
175
|
+
headers=_UA,
|
|
176
|
+
timeout=_TIMEOUT,
|
|
177
|
+
)
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
return [
|
|
180
|
+
NewsItem(r.get("title", ""), source=r.get("engine", ""), url=r.get("url", ""),
|
|
181
|
+
snippet=r.get("content", ""), published=_iso(r.get("publishedDate")))
|
|
182
|
+
for r in resp.json().get("results", [])[:limit]
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _google_news(query: str, _key, limit: int, days: int, market: str) -> list[NewsItem]:
|
|
187
|
+
hl, gl, ceid = _LOCALES.get(market, _LOCALES["us"])
|
|
188
|
+
url = (f"https://news.google.com/rss/search?q={quote_plus(query)}+when:{max(1, days)}d"
|
|
189
|
+
f"&hl={hl}&gl={gl}&ceid={ceid}")
|
|
190
|
+
resp = requests.get(url, headers=_UA, timeout=_TIMEOUT)
|
|
191
|
+
resp.raise_for_status()
|
|
192
|
+
items = []
|
|
193
|
+
for it in ET.fromstring(resp.content).iter("item"):
|
|
194
|
+
title = it.findtext("title") or ""
|
|
195
|
+
source = it.findtext("source") or ""
|
|
196
|
+
if source and title.endswith(f" - {source}"):
|
|
197
|
+
title = title[: -len(source) - 3]
|
|
198
|
+
items.append(NewsItem(title, source=source, url=it.findtext("link") or "", published=_iso(it.findtext("pubDate"))))
|
|
199
|
+
if len(items) >= limit * 3: # headroom for junk filtering
|
|
200
|
+
break
|
|
201
|
+
return items
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _yahoo(symbol_query: str, _key, limit: int, _days: int, _market: str) -> list[NewsItem]:
|
|
205
|
+
from .news import NewsFetcher
|
|
206
|
+
|
|
207
|
+
return [
|
|
208
|
+
NewsItem(a.title, source=a.source, url=a.url, snippet=a.summary if a.summary != a.title else "",
|
|
209
|
+
published=a.published.isoformat() if a.published else None)
|
|
210
|
+
for a in NewsFetcher().get_stock_news(symbol_query, limit=limit)
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class _Provider:
|
|
216
|
+
name: str
|
|
217
|
+
fn: Callable[..., list[NewsItem]]
|
|
218
|
+
credentials: list # keys or base URLs; [None] for keyless providers
|
|
219
|
+
uses_ticker: bool = False # Yahoo searches by ticker, the rest by text
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class NewsSearchService:
|
|
223
|
+
def __init__(self, environ: Optional[Mapping[str, str]] = None):
|
|
224
|
+
env = os.environ if environ is None else environ
|
|
225
|
+
self.max_age_days = int(env.get("NEWS_MAX_AGE_DAYS") or 3)
|
|
226
|
+
self.providers = [
|
|
227
|
+
p for p in (
|
|
228
|
+
_Provider("tavily", _tavily, _split(env.get("TAVILY_API_KEYS") or env.get("TAVILY_API_KEY"))),
|
|
229
|
+
_Provider("brave", _brave, _split(env.get("BRAVE_API_KEYS") or env.get("BRAVE_API_KEY"))),
|
|
230
|
+
_Provider("serpapi", _serpapi, _split(env.get("SERPAPI_API_KEYS") or env.get("SERPAPI_API_KEY"))),
|
|
231
|
+
_Provider("bocha", _bocha, _split(env.get("BOCHA_API_KEYS") or env.get("BOCHA_API_KEY"))),
|
|
232
|
+
_Provider("searxng", _searxng, _split(env.get("SEARXNG_BASE_URLS"))),
|
|
233
|
+
_Provider("google_news", _google_news, [None]),
|
|
234
|
+
_Provider("yahoo", _yahoo, [None], uses_ticker=True),
|
|
235
|
+
) if p.credentials
|
|
236
|
+
]
|
|
237
|
+
self._cache: dict[tuple, tuple[float, list[NewsItem]]] = {}
|
|
238
|
+
self._bad_until: dict[tuple[str, int], float] = {}
|
|
239
|
+
self._rotation: dict[str, int] = {}
|
|
240
|
+
self._lock = threading.Lock()
|
|
241
|
+
|
|
242
|
+
def provider_names(self) -> list[str]:
|
|
243
|
+
return [p.name for p in self.providers]
|
|
244
|
+
|
|
245
|
+
def search(self, query: str, *, ticker: str = "", market: str = "us", limit: int = 5,
|
|
246
|
+
days: Optional[int] = None) -> list[NewsItem]:
|
|
247
|
+
days = days or self.max_age_days
|
|
248
|
+
cache_key = (query, ticker, market, limit, days)
|
|
249
|
+
with self._lock:
|
|
250
|
+
hit = self._cache.get(cache_key)
|
|
251
|
+
if hit and time.monotonic() - hit[0] < _CACHE_SECONDS:
|
|
252
|
+
return hit[1]
|
|
253
|
+
|
|
254
|
+
items: list[NewsItem] = []
|
|
255
|
+
for provider in self.providers:
|
|
256
|
+
q = ticker if provider.uses_ticker else query
|
|
257
|
+
if not q:
|
|
258
|
+
continue
|
|
259
|
+
start = self._rotation.get(provider.name, 0)
|
|
260
|
+
self._rotation[provider.name] = start + 1
|
|
261
|
+
creds = list(enumerate(provider.credentials))
|
|
262
|
+
creds = creds[start % len(creds):] + creds[: start % len(creds)]
|
|
263
|
+
for index, cred in creds:
|
|
264
|
+
if self._bad_until.get((provider.name, index), 0) > time.monotonic():
|
|
265
|
+
continue
|
|
266
|
+
try:
|
|
267
|
+
found = provider.fn(q, cred, limit, days, market)
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
logger.warning("News provider %s #%d failed: %s", provider.name, index, str(exc)[:160])
|
|
270
|
+
self._bad_until[(provider.name, index)] = time.monotonic() + 10 * 60
|
|
271
|
+
continue
|
|
272
|
+
items = self._clean(found, provider.name, limit, days)
|
|
273
|
+
if items:
|
|
274
|
+
break
|
|
275
|
+
if items:
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
with self._lock:
|
|
279
|
+
self._cache[cache_key] = (time.monotonic(), items)
|
|
280
|
+
return items
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _clean(found: list[NewsItem], provider: str, limit: int, days: int) -> list[NewsItem]:
|
|
284
|
+
seen, out = set(), []
|
|
285
|
+
for item in found:
|
|
286
|
+
title = " ".join((item.title or "").split())
|
|
287
|
+
key = re.sub(r"\W+", "", title.lower())[:80]
|
|
288
|
+
if not title or key in seen or _JUNK.search(title) or not _fresh(item, days):
|
|
289
|
+
continue
|
|
290
|
+
seen.add(key)
|
|
291
|
+
item.title, item.provider = title, provider
|
|
292
|
+
out.append(item)
|
|
293
|
+
out.sort(key=lambda i: i.published or "", reverse=True)
|
|
294
|
+
return out[:limit]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@lru_cache(maxsize=512)
|
|
298
|
+
def company_name(symbol: str, market: str) -> str:
|
|
299
|
+
"""Best human name for a news query ("Reliance Industries" beats
|
|
300
|
+
"RELIANCE"). One cached Yahoo lookup per symbol."""
|
|
301
|
+
if market != "crypto":
|
|
302
|
+
try:
|
|
303
|
+
import yfinance as yf
|
|
304
|
+
|
|
305
|
+
from ..services.analysis_run import yahoo_symbol
|
|
306
|
+
|
|
307
|
+
info = yf.Ticker(yahoo_symbol(symbol, market)).info or {}
|
|
308
|
+
name = info.get("shortName") or info.get("longName")
|
|
309
|
+
if name:
|
|
310
|
+
return name
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
return symbol
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_SUFFIXES = re.compile(r"\b(limited|ltd\.?|inc\.?|corp(oration)?\.?|co\.?|plc|holdings?|common stock|class [a-z])\b", re.I)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def news_query(symbol: str, market: str, name: Optional[str] = None) -> str:
|
|
320
|
+
name = _SUFFIXES.sub("", name or company_name(symbol, market))
|
|
321
|
+
name = " ".join(re.sub(r"[.,()&]+", " ", name).split()).title() or symbol
|
|
322
|
+
if market == "crypto":
|
|
323
|
+
return f"{symbol.split('-')[0]} crypto"
|
|
324
|
+
return f"{name} share" if market == "india" else f"{name} stock"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_service: Optional[NewsSearchService] = None
|
|
328
|
+
_service_lock = threading.Lock()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def get_news_service() -> NewsSearchService:
|
|
332
|
+
global _service
|
|
333
|
+
with _service_lock:
|
|
334
|
+
if _service is None:
|
|
335
|
+
_service = NewsSearchService()
|
|
336
|
+
return _service
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def stock_news(symbol: str, market: str, limit: int = 5, name: Optional[str] = None) -> list[dict]:
|
|
340
|
+
"""Recent headlines for one stock, as plain dicts (picklable/JSON-safe)."""
|
|
341
|
+
from ..services.analysis_run import yahoo_symbol
|
|
342
|
+
|
|
343
|
+
service = get_news_service()
|
|
344
|
+
items = service.search(news_query(symbol, market, name), ticker=yahoo_symbol(symbol, market),
|
|
345
|
+
market=market, limit=limit)
|
|
346
|
+
return [i.to_dict() for i in items]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""NSE bulk and block deals: large trades by named clients (funds, promoters,
|
|
2
|
+
brokers) that the exchange publishes daily.
|
|
3
|
+
|
|
4
|
+
NSE's archive CSVs (the same host the listings use, which works from cloud
|
|
5
|
+
servers) only hold the latest trading day, so every fetch is also saved to
|
|
6
|
+
~/.marketmyna/deals/<date>.json (the app data volume) and history is built
|
|
7
|
+
from those files. The alert monitor refreshes hourly in the evening so days
|
|
8
|
+
are collected even when nobody opens the page.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from datetime import date, datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Optional
|
|
22
|
+
|
|
23
|
+
from marketmyna.core.home import app_home
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
URLS = {"bulk": "https://nsearchives.nseindia.com/content/equities/bulk.csv",
|
|
28
|
+
"block": "https://nsearchives.nseindia.com/content/equities/block.csv"}
|
|
29
|
+
CACHE_SECONDS = 1800
|
|
30
|
+
|
|
31
|
+
Fetch = Callable[[str], str]
|
|
32
|
+
|
|
33
|
+
_last: dict[str, float] = {}
|
|
34
|
+
_lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _dir() -> Path:
|
|
38
|
+
path = app_home() / "deals"
|
|
39
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _http(url: str) -> str:
|
|
44
|
+
import requests
|
|
45
|
+
|
|
46
|
+
resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
|
|
47
|
+
resp.raise_for_status()
|
|
48
|
+
return resp.text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _num(v) -> Optional[float]:
|
|
52
|
+
try:
|
|
53
|
+
return float(str(v).replace(",", ""))
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse(text: str, kind: str) -> list[dict]:
|
|
59
|
+
rows = []
|
|
60
|
+
for r in csv.DictReader(io.StringIO(text.strip())):
|
|
61
|
+
r = {k.strip(): (v or "").strip() for k, v in r.items() if k}
|
|
62
|
+
try:
|
|
63
|
+
day = datetime.strptime(r["Date"], "%d-%b-%Y").date().isoformat()
|
|
64
|
+
except (KeyError, ValueError):
|
|
65
|
+
continue
|
|
66
|
+
qty, price = _num(r.get("Quantity Traded")), _num(r.get("Trade Price / Wght. Avg. Price"))
|
|
67
|
+
rows.append({"date": day, "kind": kind, "symbol": r.get("Symbol", "").upper(), "name": r.get("Security Name"),
|
|
68
|
+
"client": r.get("Client Name"), "side": (r.get("Buy/Sell") or "").lower(), "qty": qty, "price": price,
|
|
69
|
+
"value": qty * price if qty and price else None,
|
|
70
|
+
"remarks": None if r.get("Remarks") in (None, "", "-") else r.get("Remarks")})
|
|
71
|
+
return rows
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def refresh(fetch: Fetch = _http, force: bool = False) -> int:
|
|
75
|
+
"""Fetch today's files (at most every 30 min) and merge them into the per-day store."""
|
|
76
|
+
with _lock:
|
|
77
|
+
if not force and time.time() - _last.get("fetch", 0) < CACHE_SECONDS:
|
|
78
|
+
return 0
|
|
79
|
+
_last["fetch"] = time.time()
|
|
80
|
+
fresh: list[dict] = []
|
|
81
|
+
for kind, url in URLS.items():
|
|
82
|
+
try:
|
|
83
|
+
fresh += parse(fetch(url), kind)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
logger.warning("NSE %s deals unavailable: %s", kind, exc)
|
|
86
|
+
by_day: dict[str, list[dict]] = {}
|
|
87
|
+
for r in fresh:
|
|
88
|
+
by_day.setdefault(r["date"], []).append(r)
|
|
89
|
+
for day, rows in by_day.items():
|
|
90
|
+
path = _dir() / f"{day}.json"
|
|
91
|
+
existing = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
|
|
92
|
+
seen = {json.dumps(r, sort_keys=True) for r in existing}
|
|
93
|
+
merged = existing + [r for r in rows if json.dumps(r, sort_keys=True) not in seen]
|
|
94
|
+
path.write_text(json.dumps(merged), encoding="utf-8")
|
|
95
|
+
return len(fresh)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def recent(days: int = 30, symbol: Optional[str] = None, today: Optional[date] = None, fetch: Fetch = _http) -> dict:
|
|
99
|
+
refresh(fetch)
|
|
100
|
+
today = today or date.today()
|
|
101
|
+
since = (today - timedelta(days=days)).isoformat()
|
|
102
|
+
rows: list[dict] = []
|
|
103
|
+
stored = sorted(p.stem for p in _dir().glob("*.json"))
|
|
104
|
+
for day in stored:
|
|
105
|
+
if day >= since:
|
|
106
|
+
rows += json.loads((_dir() / f"{day}.json").read_text(encoding="utf-8"))
|
|
107
|
+
if symbol:
|
|
108
|
+
rows = [r for r in rows if r["symbol"] == symbol.upper()]
|
|
109
|
+
rows = _merge_kinds(rows)
|
|
110
|
+
rows.sort(key=lambda r: (r["date"], r.get("value") or 0), reverse=True)
|
|
111
|
+
return {"days": days, "history_from": stored[0] if stored else None, "latest": stored[-1] if stored else None,
|
|
112
|
+
"count": len(rows), "deals": rows[:300]}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _merge_kinds(rows: list[dict]) -> list[dict]:
|
|
116
|
+
"""A block trade large enough to be a bulk deal is listed in both files:
|
|
117
|
+
keep one row, kind "block & bulk"."""
|
|
118
|
+
merged: dict[tuple, dict] = {}
|
|
119
|
+
for r in rows:
|
|
120
|
+
key = (r["date"], r["symbol"], r.get("client"), r["side"], r.get("qty"), r.get("price"))
|
|
121
|
+
if key in merged and merged[key]["kind"] != r["kind"]:
|
|
122
|
+
merged[key] = {**merged[key], "kind": "block & bulk", "remarks": merged[key].get("remarks") or r.get("remarks")}
|
|
123
|
+
else:
|
|
124
|
+
merged.setdefault(key, r)
|
|
125
|
+
return list(merged.values())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def maybe_refresh(now: Optional[datetime] = None) -> None:
|
|
129
|
+
"""Alert-loop hook: NSE posts the files after the close; refresh hourly 16:00-21:59 IST."""
|
|
130
|
+
from zoneinfo import ZoneInfo
|
|
131
|
+
|
|
132
|
+
local = (now or datetime.now(ZoneInfo("Asia/Kolkata"))).astimezone(ZoneInfo("Asia/Kolkata"))
|
|
133
|
+
with _lock:
|
|
134
|
+
due = time.time() - _last.get("loop", 0) >= 3600
|
|
135
|
+
if 16 <= local.hour < 22 and local.weekday() < 5 and due:
|
|
136
|
+
with _lock:
|
|
137
|
+
_last["loop"] = time.time()
|
|
138
|
+
refresh(force=True)
|