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,548 @@
|
|
|
1
|
+
"""In-process runtime scheduler.
|
|
2
|
+
|
|
3
|
+
A background thread wakes up every CHECK_INTERVAL_SECONDS and asks "is a
|
|
4
|
+
run due?" against the persisted ScheduleConfig (marketmyna/services/schedule_config.py).
|
|
5
|
+
When it is, every configured symbol is analyzed in its own isolated
|
|
6
|
+
**child process** (multiprocessing, spawn context - matching
|
|
7
|
+
daily_stock_analysis's own RuntimeSchedulerService design) with a
|
|
8
|
+
watchdog timeout, so a long-running or crashing analysis can never take
|
|
9
|
+
the API server down with it. Results are written back to the persisted
|
|
10
|
+
config and delivered per channel/language by analysis_run.deliver_report().
|
|
11
|
+
|
|
12
|
+
Both the timer loop and the manual "run now" API endpoint call the same
|
|
13
|
+
`run_now()` method, so there is exactly one "do a run" code path.
|
|
14
|
+
|
|
15
|
+
Multiprocessing/pickling note: a spawn-context Process target must be a
|
|
16
|
+
plain module-level function, not a bound method or closure, and its
|
|
17
|
+
arguments must be picklable. `_run_one_symbol_in_process` below is that
|
|
18
|
+
function - it does NOT receive the parent's live Config object (pickling
|
|
19
|
+
a whole Config, or an AgentOrchestrator, across the process boundary is
|
|
20
|
+
fragile and unnecessary); it receives a small dict of plain LLM-related
|
|
21
|
+
values and reconstructs whatever it needs from scratch inside the child.
|
|
22
|
+
|
|
23
|
+
Deployment caveat worth flagging explicitly: a spawned child process
|
|
24
|
+
re-imports this module by its dotted path (`marketmyna.services.runtime_scheduler`).
|
|
25
|
+
That only works if `src`/`api` are resolvable as top-level packages in a
|
|
26
|
+
FRESH interpreter, i.e. the project root is on that interpreter's
|
|
27
|
+
sys.path (or PYTHONPATH) independent of the parent process's own
|
|
28
|
+
sys.path tricks. Whatever launches the real API server (uvicorn from the
|
|
29
|
+
project root, a proper package install, etc) must ensure that's true;
|
|
30
|
+
this was not exercised against a real spawned-from-uvicorn process in
|
|
31
|
+
this sandbox (fastapi itself isn't installed here), only against a
|
|
32
|
+
directly-invoked python process where cwd is already on sys.path.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import contextvars
|
|
38
|
+
import logging
|
|
39
|
+
import multiprocessing
|
|
40
|
+
import os
|
|
41
|
+
import threading
|
|
42
|
+
import time
|
|
43
|
+
from datetime import datetime, timedelta, timezone
|
|
44
|
+
from typing import Callable, Optional
|
|
45
|
+
|
|
46
|
+
from ..core import tenant
|
|
47
|
+
from .analysis_run import analyze_symbol, deliver_report, notify_failure
|
|
48
|
+
from .schedule_config import ScheduleConfig, ScheduleConfigStore, resolve_funds, resolve_targets
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
CHECK_INTERVAL_SECONDS = 30.0
|
|
53
|
+
DEFAULT_WATCHDOG_SECONDS = 30 * 60.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _extract_llm_kwargs(config) -> dict:
|
|
57
|
+
"""The only Config fields the child process actually needs (to build
|
|
58
|
+
its own GenerationBackend) - deliberately not the whole Config, so
|
|
59
|
+
the multiprocessing payload stays small and unambiguous."""
|
|
60
|
+
return {
|
|
61
|
+
"llm_backend": getattr(config, "llm_backend", None),
|
|
62
|
+
"llm_model": getattr(config, "llm_model", None),
|
|
63
|
+
"llm_api_base": getattr(config, "llm_api_base", None),
|
|
64
|
+
"llm_api_key": getattr(config, "llm_api_key", None),
|
|
65
|
+
"llm_timeout_seconds": getattr(config, "llm_timeout_seconds", None),
|
|
66
|
+
"llm_max_tokens": getattr(config, "llm_max_tokens", None),
|
|
67
|
+
"llm_temperature": getattr(config, "llm_temperature", None),
|
|
68
|
+
"llm_api_keys": getattr(config, "llm_api_keys", None),
|
|
69
|
+
"llm_fallback_models": getattr(config, "llm_fallback_models", None),
|
|
70
|
+
"llm_channels": getattr(config, "llm_channels", None),
|
|
71
|
+
# Per-provider keys, the fast tier and the shared cache/usage db.
|
|
72
|
+
**{
|
|
73
|
+
name: getattr(config, name, None)
|
|
74
|
+
for name in (
|
|
75
|
+
"gemini_api_key", "groq_api_key", "openrouter_api_key", "openai_api_key", "anthropic_api_key",
|
|
76
|
+
"deepseek_api_key", "mistral_api_key", "cerebras_api_key", "nvidia_api_key", "ollama_base_url", "openai_base_url", "llm_fast_model",
|
|
77
|
+
"llm_cache_hours",
|
|
78
|
+
"db_path",
|
|
79
|
+
# Accounts mode: the child meters AI and reads the user's own key.
|
|
80
|
+
"accounts_enabled", "admin_email", "secret_key", "app_dir",
|
|
81
|
+
)
|
|
82
|
+
if getattr(config, name, None) is not None
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _analyze_in_child(
|
|
88
|
+
symbol: str, market: str, mode: str, llm_kwargs: dict, sections: Optional[list] = None,
|
|
89
|
+
user_id: Optional[int] = None, shared: bool = False,
|
|
90
|
+
) -> dict:
|
|
91
|
+
"""One analysis inside a child process (a one-off process or a pool
|
|
92
|
+
worker). Module-level so spawn can pickle it; never raises."""
|
|
93
|
+
try:
|
|
94
|
+
import contextlib
|
|
95
|
+
|
|
96
|
+
from ..core import tenant
|
|
97
|
+
from ..core.config import Config
|
|
98
|
+
from ..utils import yahoo_gate
|
|
99
|
+
from .analysis_run import analyze_symbol
|
|
100
|
+
|
|
101
|
+
# One Yahoo pace for the whole server; a worker waits out a 429 cooldown.
|
|
102
|
+
yahoo_gate.install()
|
|
103
|
+
yahoo_gate.patient()
|
|
104
|
+
|
|
105
|
+
config = Config(**llm_kwargs)
|
|
106
|
+
scope = contextlib.nullcontext()
|
|
107
|
+
if config.accounts_enabled:
|
|
108
|
+
# A fresh process: re-apply the owner's data scope.
|
|
109
|
+
tenant.enable()
|
|
110
|
+
scope = tenant.user_scope(user_id)
|
|
111
|
+
with scope:
|
|
112
|
+
return analyze_symbol(symbol, market, mode, config, sections=sections)
|
|
113
|
+
except Exception as exc: # noqa: BLE001 - reported to parent, not raised in the child
|
|
114
|
+
return {"ok": False, "symbol": symbol, "market": market, "error": str(exc)}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_one_symbol_in_process(
|
|
118
|
+
symbol: str, market: str, mode: str, llm_kwargs: dict, result_queue, sections: Optional[list] = None,
|
|
119
|
+
user_id: Optional[int] = None, shared: bool = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Module-level, picklable multiprocessing target for a one-off child
|
|
122
|
+
("Run now"). Every outcome is reported through result_queue so the parent
|
|
123
|
+
always gets a clean answer rather than a silently-dead child."""
|
|
124
|
+
result_queue.put(_analyze_in_child(symbol, market, mode, llm_kwargs, sections, user_id, shared))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Seconds to wait before retrying the stocks that hit a rate limit.
|
|
128
|
+
RETRY_PAUSE = float(os.environ.get("SCHEDULER_RETRY_PAUSE", 60))
|
|
129
|
+
_LIMIT_SIGNS = ("rate_limited", "rate limit", "too many requests", "429", "cooling down",
|
|
130
|
+
"all sources exhausted", "quota")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _hit_limit(result: dict) -> bool:
|
|
134
|
+
"""A failure that a later try may not hit: an AI or data source said
|
|
135
|
+
"slow down", not "this stock doesn't exist"."""
|
|
136
|
+
if result.get("ok"):
|
|
137
|
+
return False
|
|
138
|
+
error = str(result.get("error") or "").lower()
|
|
139
|
+
return any(sign in error for sign in _LIMIT_SIGNS)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class RuntimeSchedulerService:
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
config,
|
|
146
|
+
watchdog_seconds: float = DEFAULT_WATCHDOG_SECONDS,
|
|
147
|
+
orchestrator_factory: Optional[Callable[[], object]] = None,
|
|
148
|
+
mp_start_method: str = "spawn",
|
|
149
|
+
):
|
|
150
|
+
"""`orchestrator_factory`, when given, makes `run_now()` build and
|
|
151
|
+
run an AgentOrchestrator in-process (no multiprocessing) via that
|
|
152
|
+
factory instead - the injection point a test uses to swap in a
|
|
153
|
+
fake orchestrator/backend without touching the real child-process
|
|
154
|
+
path at all."""
|
|
155
|
+
self._config = config
|
|
156
|
+
self._store = ScheduleConfigStore(config=config)
|
|
157
|
+
self._watchdog_seconds = watchdog_seconds
|
|
158
|
+
self._orchestrator_factory = orchestrator_factory
|
|
159
|
+
self._mp_context = multiprocessing.get_context(mp_start_method)
|
|
160
|
+
|
|
161
|
+
self._stop_event = threading.Event()
|
|
162
|
+
self._thread: Optional[threading.Thread] = None
|
|
163
|
+
# Timer waves run on long-lived workers (marketmyna/services/worker_pool.py);
|
|
164
|
+
# tests with a fake orchestrator run everything in-process instead.
|
|
165
|
+
self.use_pool = orchestrator_factory is None
|
|
166
|
+
self._pool = None
|
|
167
|
+
# "Run now" per account: {"running": bool, "result": dict | None}.
|
|
168
|
+
self._now: dict = {}
|
|
169
|
+
self._now_lock = threading.Lock()
|
|
170
|
+
|
|
171
|
+
# ---- lifecycle ----
|
|
172
|
+
|
|
173
|
+
def start(self) -> None:
|
|
174
|
+
if self._thread is not None and self._thread.is_alive():
|
|
175
|
+
return
|
|
176
|
+
self._stop_event.clear()
|
|
177
|
+
self._thread = threading.Thread(target=self._loop, name="marketmyna-scheduler", daemon=True)
|
|
178
|
+
self._thread.start()
|
|
179
|
+
|
|
180
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
181
|
+
self._stop_event.set()
|
|
182
|
+
if self._thread is not None:
|
|
183
|
+
self._thread.join(timeout=timeout)
|
|
184
|
+
if self._pool is not None:
|
|
185
|
+
self._pool.close()
|
|
186
|
+
self._pool = None
|
|
187
|
+
|
|
188
|
+
def is_running(self) -> bool:
|
|
189
|
+
return self._thread is not None and self._thread.is_alive()
|
|
190
|
+
|
|
191
|
+
# ---- timer loop ----
|
|
192
|
+
|
|
193
|
+
def _loop(self) -> None:
|
|
194
|
+
while not self._stop_event.is_set():
|
|
195
|
+
try:
|
|
196
|
+
self._check_and_maybe_run()
|
|
197
|
+
except Exception:
|
|
198
|
+
logger.exception("Scheduler check-and-run failed")
|
|
199
|
+
self._stop_event.wait(CHECK_INTERVAL_SECONDS)
|
|
200
|
+
|
|
201
|
+
def _check_and_maybe_run(self) -> None:
|
|
202
|
+
"""One tick: find every account whose report is due, then run them all
|
|
203
|
+
as one wave (shared analyses, parallel workers - see _run_wave)."""
|
|
204
|
+
from .accounts import for_each_user
|
|
205
|
+
|
|
206
|
+
due: list[Optional[int]] = []
|
|
207
|
+
|
|
208
|
+
def collect() -> None:
|
|
209
|
+
if self.is_due(self._store.load(), datetime.now(timezone.utc), self._user_zone()):
|
|
210
|
+
due.append(tenant.current_user_id())
|
|
211
|
+
|
|
212
|
+
# Accounts mode: every user's own schedule in their own scope.
|
|
213
|
+
# Otherwise the one schedule.
|
|
214
|
+
for_each_user(self._config, collect, "Scheduled report check")
|
|
215
|
+
if due:
|
|
216
|
+
self._run_wave(due, use_pool=self.use_pool)
|
|
217
|
+
|
|
218
|
+
def _user_zone(self):
|
|
219
|
+
"""The current account's time zone (single-user mode: the default)."""
|
|
220
|
+
from .schedule_time import zone
|
|
221
|
+
|
|
222
|
+
uid = tenant.current_user_id()
|
|
223
|
+
if not tenant.enabled() or uid is None:
|
|
224
|
+
return zone(None)
|
|
225
|
+
from ..storage import DatabaseManager, UserRecord, get_db_url
|
|
226
|
+
|
|
227
|
+
with tenant.system_scope(), DatabaseManager.instance(get_db_url(config=self._config)).get_session() as session:
|
|
228
|
+
user = session.get(UserRecord, uid)
|
|
229
|
+
return zone(user.timezone if user else None)
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def is_due(cfg: ScheduleConfig, now: datetime, tz=None) -> bool:
|
|
233
|
+
"""Public/static so it can be unit-tested with a synthetic
|
|
234
|
+
ScheduleConfig + timestamp, no threading or DB involved.
|
|
235
|
+
|
|
236
|
+
`tz`: the account's time zone. A daily time is that local time
|
|
237
|
+
(24-hour), so 08:45 for a user in New York runs at 08:45 New York
|
|
238
|
+
time. Naive datetimes (now, last_run_at) are the server's local time."""
|
|
239
|
+
if not cfg.enabled or not cfg.has_targets_configured():
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
if cfg.interval_minutes:
|
|
243
|
+
if cfg.last_run_at is None:
|
|
244
|
+
return True
|
|
245
|
+
last = cfg.last_run_at
|
|
246
|
+
if now.tzinfo is not None:
|
|
247
|
+
last = last.astimezone(now.tzinfo) if last.tzinfo else last.astimezone()
|
|
248
|
+
return now - last >= timedelta(minutes=cfg.interval_minutes)
|
|
249
|
+
|
|
250
|
+
if cfg.time_of_day:
|
|
251
|
+
try:
|
|
252
|
+
hour, minute = (int(part) for part in cfg.time_of_day.split(":", 1))
|
|
253
|
+
except ValueError:
|
|
254
|
+
logger.warning("Invalid time_of_day %r, ignoring", cfg.time_of_day)
|
|
255
|
+
return False
|
|
256
|
+
if tz is not None:
|
|
257
|
+
now = now.astimezone(tz)
|
|
258
|
+
target_today = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
259
|
+
if now < target_today:
|
|
260
|
+
return False
|
|
261
|
+
last = cfg.last_run_at
|
|
262
|
+
if last is not None and now.tzinfo is not None:
|
|
263
|
+
last = last.astimezone(now.tzinfo) # naive = server local time
|
|
264
|
+
if last is not None and last >= target_today:
|
|
265
|
+
return False # already ran today's slot
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
# ---- run execution (shared by the loop and the manual endpoint) ----
|
|
271
|
+
|
|
272
|
+
def run_now(self) -> dict:
|
|
273
|
+
"""Run the current account's report now (the "Run now" button and
|
|
274
|
+
single-user mode). Same wave path as the timer, with one account."""
|
|
275
|
+
return self._run_wave([tenant.current_user_id()])[tenant.current_user_id()]
|
|
276
|
+
|
|
277
|
+
def start_run_now(self) -> bool:
|
|
278
|
+
"""run_now() in the background, since a report can outlast a proxy's
|
|
279
|
+
request timeout (Cloudflare: 100 s). False if this account's run is
|
|
280
|
+
already going. The page polls run_now_state()."""
|
|
281
|
+
uid = tenant.current_user_id()
|
|
282
|
+
with self._now_lock:
|
|
283
|
+
if self._now.get(uid, {}).get("running"):
|
|
284
|
+
return False
|
|
285
|
+
self._now[uid] = {"running": True, "result": None}
|
|
286
|
+
|
|
287
|
+
def work() -> None:
|
|
288
|
+
try:
|
|
289
|
+
result = self.run_now()
|
|
290
|
+
except Exception as exc: # reported to the page, not raised in a bare thread
|
|
291
|
+
logger.exception("Run now failed")
|
|
292
|
+
result = {"status": "failed", "results": [], "summary": f"The run failed: {exc}"}
|
|
293
|
+
with self._now_lock:
|
|
294
|
+
self._now[uid] = {"running": False, "result": result}
|
|
295
|
+
|
|
296
|
+
# The thread keeps the request's context: the account's scope, AI key and compliance mode.
|
|
297
|
+
threading.Thread(target=contextvars.copy_context().run, args=(work,), name="run-now", daemon=True).start()
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
def run_now_state(self) -> dict:
|
|
301
|
+
with self._now_lock:
|
|
302
|
+
return dict(self._now.get(tenant.current_user_id(), {"running": False, "result": None}))
|
|
303
|
+
|
|
304
|
+
def _run_wave(self, user_ids: list, use_pool: bool = False) -> dict:
|
|
305
|
+
"""Every due account's report in one go.
|
|
306
|
+
|
|
307
|
+
1. Plan, in each account's scope: its stocks, sections, AI access.
|
|
308
|
+
2. Analyse each (stock, market, depth) once per group of accounts that
|
|
309
|
+
can share it - same AI access kind and compliance mode; an account
|
|
310
|
+
on its own AI key never shares - on `schedule_slots.workers()`
|
|
311
|
+
parallel workers.
|
|
312
|
+
3. Build and send each account's report from the shared results, in
|
|
313
|
+
its own scope (its sections, language and channels).
|
|
314
|
+
"""
|
|
315
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
316
|
+
|
|
317
|
+
plans_by_user: dict = {}
|
|
318
|
+
for uid in user_ids:
|
|
319
|
+
with self._scope(uid):
|
|
320
|
+
try:
|
|
321
|
+
plans_by_user[uid] = self._plan(uid)
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
logger.exception("Could not plan the report for user %s", uid)
|
|
324
|
+
plans_by_user[uid] = {"error": str(exc)}
|
|
325
|
+
|
|
326
|
+
# One job per shareable analysis; each remembers the account it runs as.
|
|
327
|
+
jobs: dict = {}
|
|
328
|
+
for uid, plan in plans_by_user.items():
|
|
329
|
+
if plan.get("smart") or plan.get("error"):
|
|
330
|
+
continue
|
|
331
|
+
for t in plan["targets"]:
|
|
332
|
+
key = (t.symbol, t.market, plan["mode"], plan["share"])
|
|
333
|
+
job = jobs.setdefault(key, {"user_id": uid, "sections": set(), "shared": False})
|
|
334
|
+
job["sections"].update(plan["sections"])
|
|
335
|
+
if job["user_id"] != uid:
|
|
336
|
+
job["shared"] = True
|
|
337
|
+
|
|
338
|
+
from .schedule_time import workers
|
|
339
|
+
|
|
340
|
+
results: dict = {}
|
|
341
|
+
if jobs and use_pool:
|
|
342
|
+
keys = list(jobs)
|
|
343
|
+
llm_kwargs = _extract_llm_kwargs(self._config)
|
|
344
|
+
tasks = [(k[0], k[1], k[2], llm_kwargs, sorted(jobs[k]["sections"]), jobs[k]["user_id"], jobs[k]["shared"])
|
|
345
|
+
for k in keys]
|
|
346
|
+
results = dict(zip(keys, self._worker_pool().run_many(tasks)))
|
|
347
|
+
elif jobs:
|
|
348
|
+
with ThreadPoolExecutor(max_workers=min(workers(self._config), len(jobs))) as pool:
|
|
349
|
+
futures = {
|
|
350
|
+
key: pool.submit(self._run_job, key[0], key[1], key[2], sorted(job["sections"]),
|
|
351
|
+
job["user_id"], job["shared"])
|
|
352
|
+
for key, job in jobs.items()
|
|
353
|
+
}
|
|
354
|
+
for key, future in futures.items():
|
|
355
|
+
results[key] = future.result()
|
|
356
|
+
|
|
357
|
+
# Free AI and data tiers throttle bursts (Gemini / Groq 429, Yahoo "Too
|
|
358
|
+
# Many Requests"). Stocks that hit a limit get one more try, one at a
|
|
359
|
+
# time, once the limits have had a moment to reset.
|
|
360
|
+
retry = [key for key, result in results.items() if _hit_limit(result)]
|
|
361
|
+
if retry:
|
|
362
|
+
logger.info("Retrying %d stock(s) that hit a rate limit in %.0fs: %s",
|
|
363
|
+
len(retry), RETRY_PAUSE, ", ".join(k[0] for k in retry))
|
|
364
|
+
time.sleep(RETRY_PAUSE)
|
|
365
|
+
for key in retry:
|
|
366
|
+
job = jobs[key]
|
|
367
|
+
again = self._run_job(key[0], key[1], key[2], sorted(job["sections"]), job["user_id"], job["shared"])
|
|
368
|
+
if again.get("ok") or (again.get("dashboard") and not results[key].get("dashboard")):
|
|
369
|
+
results[key] = again
|
|
370
|
+
|
|
371
|
+
def deliver(uid):
|
|
372
|
+
with self._scope(uid):
|
|
373
|
+
return self._finish(uid, plans_by_user[uid], results)
|
|
374
|
+
|
|
375
|
+
out: dict = {}
|
|
376
|
+
with ThreadPoolExecutor(max_workers=max(1, min(workers(self._config), len(plans_by_user)))) as pool:
|
|
377
|
+
for uid, outcome in zip(plans_by_user, pool.map(deliver, plans_by_user)):
|
|
378
|
+
out[uid] = outcome
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
def _worker_pool(self):
|
|
382
|
+
"""The long-lived workers, created on first use and kept between
|
|
383
|
+
waves; resized when the admin changes the worker count."""
|
|
384
|
+
from .schedule_time import workers
|
|
385
|
+
from .worker_pool import WorkerPool
|
|
386
|
+
|
|
387
|
+
if self._pool is None:
|
|
388
|
+
self._pool = WorkerPool(self._mp_context, _analyze_in_child, workers(self._config), self._watchdog_seconds)
|
|
389
|
+
else:
|
|
390
|
+
self._pool.resize(workers(self._config))
|
|
391
|
+
return self._pool
|
|
392
|
+
|
|
393
|
+
def _scope(self, uid):
|
|
394
|
+
import contextlib
|
|
395
|
+
|
|
396
|
+
return tenant.user_scope(uid) if tenant.enabled() and uid is not None else contextlib.nullcontext()
|
|
397
|
+
|
|
398
|
+
def _run_job(self, symbol, market, mode, sections, user_id, shared) -> dict:
|
|
399
|
+
try:
|
|
400
|
+
if user_id is not None and tenant.enabled():
|
|
401
|
+
with tenant.user_scope(user_id):
|
|
402
|
+
return self._run_one_symbol(symbol, market, mode, sections, user_id=user_id, shared=shared)
|
|
403
|
+
return self._run_one_symbol(symbol, market, mode, sections)
|
|
404
|
+
except Exception as exc: # one stock never sinks the wave
|
|
405
|
+
return {"ok": False, "symbol": symbol, "market": market, "error": str(exc)}
|
|
406
|
+
|
|
407
|
+
def _plan(self, uid) -> dict:
|
|
408
|
+
"""What this account's run covers (runs in its scope)."""
|
|
409
|
+
from ..core.compliance import mode as compliance_mode
|
|
410
|
+
|
|
411
|
+
cfg = self._store.load()
|
|
412
|
+
prefs = cfg.report_prefs(timezone=self._user_zone().key)
|
|
413
|
+
run_mode = cfg.mode or "standard"
|
|
414
|
+
prefs.mode = run_mode
|
|
415
|
+
try:
|
|
416
|
+
targets = resolve_targets(cfg, self._config)
|
|
417
|
+
prefs.funds = resolve_funds(cfg, self._config)
|
|
418
|
+
except Exception as exc:
|
|
419
|
+
logger.exception("Could not read the run's watchlists/portfolios")
|
|
420
|
+
targets = []
|
|
421
|
+
notify_failure(self._config, f"Could not read the watchlists/portfolios to run: {exc}")
|
|
422
|
+
# Smart messaging strips every call, level and stop (smart_report.without_calls).
|
|
423
|
+
smart = bool(prefs.smart and targets)
|
|
424
|
+
return {"cfg": cfg, "prefs": prefs, "targets": targets, "mode": run_mode,
|
|
425
|
+
"sections": sorted(prefs.all_sections()), "smart": smart,
|
|
426
|
+
"share": compliance_mode()}
|
|
427
|
+
|
|
428
|
+
def _finish(self, uid, plan: dict, results: dict) -> dict:
|
|
429
|
+
"""Build, send and record one account's report (runs in its scope)."""
|
|
430
|
+
import copy
|
|
431
|
+
|
|
432
|
+
if plan.get("error"):
|
|
433
|
+
return {"status": "failed", "results": [], "summary": plan["error"]}
|
|
434
|
+
cfg, prefs, targets = plan["cfg"], plan["prefs"], plan["targets"]
|
|
435
|
+
if plan["smart"]:
|
|
436
|
+
return self._run_smart(cfg, prefs, targets)
|
|
437
|
+
own = [copy.deepcopy(results[(t.symbol, t.market, plan["mode"], plan["share"])]) for t in targets]
|
|
438
|
+
|
|
439
|
+
if not own:
|
|
440
|
+
status = "no_symbols_configured"
|
|
441
|
+
elif all(r.get("ok") for r in own):
|
|
442
|
+
status = "ok"
|
|
443
|
+
elif any(r.get("ok") for r in own):
|
|
444
|
+
status = "partial_failure"
|
|
445
|
+
else:
|
|
446
|
+
status = "failed"
|
|
447
|
+
|
|
448
|
+
reports: dict = {}
|
|
449
|
+
if own:
|
|
450
|
+
try:
|
|
451
|
+
# Renders one report per language and sends each channel
|
|
452
|
+
# its preferred language.
|
|
453
|
+
reports, _ = deliver_report(own, prefs, self._config)
|
|
454
|
+
except Exception as exc:
|
|
455
|
+
logger.exception("Failed to build or dispatch scheduler report")
|
|
456
|
+
notify_failure(self._config, f"The report could not be built or sent: {exc}")
|
|
457
|
+
# Alert only when nothing at all came back - missing AI decisions
|
|
458
|
+
# (no model configured) still produce a useful report.
|
|
459
|
+
if not any(r.get("dashboard") for r in own):
|
|
460
|
+
notify_failure(
|
|
461
|
+
self._config,
|
|
462
|
+
"No stock could be analyzed.\n"
|
|
463
|
+
+ "\n".join(f"{r['symbol']}: {r.get('error', 'no data')}" for r in own),
|
|
464
|
+
)
|
|
465
|
+
default = reports.get(prefs.language) or next(iter(reports.values()), None)
|
|
466
|
+
summary = default[1] if default else (
|
|
467
|
+
"Nothing to run: the chosen watchlists / portfolios / symbols are empty."
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
cfg = self._store.load() # settings may have changed while the wave ran
|
|
471
|
+
cfg.last_run_at = datetime.now()
|
|
472
|
+
cfg.last_run_status = status
|
|
473
|
+
cfg.last_run_summary = summary
|
|
474
|
+
cfg.stock_count = len(targets)
|
|
475
|
+
self._store.save(cfg)
|
|
476
|
+
|
|
477
|
+
return {"status": status, "results": own, "summary": summary}
|
|
478
|
+
|
|
479
|
+
def _run_smart(self, cfg, prefs, targets) -> dict:
|
|
480
|
+
"""Smart messaging: computed metrics for every stock, AI for the top
|
|
481
|
+
few, one short message + a saved full report (see smart_report)."""
|
|
482
|
+
from .smart_report import run_smart
|
|
483
|
+
|
|
484
|
+
analyze = None
|
|
485
|
+
if self._orchestrator_factory is not None: # tests: in-process fake pipeline
|
|
486
|
+
analyze = lambda sym, mkt, mode, config: analyze_symbol( # noqa: E731
|
|
487
|
+
sym, mkt, mode, config, orchestrator=self._orchestrator_factory())
|
|
488
|
+
try:
|
|
489
|
+
reports, _, rows, token, _ = run_smart(targets, prefs, self._config, analyze=analyze)
|
|
490
|
+
except Exception as exc:
|
|
491
|
+
logger.exception("Smart report failed")
|
|
492
|
+
notify_failure(self._config, f"The smart report could not be built or sent: {exc}")
|
|
493
|
+
reports, rows, token = {}, [], None
|
|
494
|
+
ok = [r for r in rows if "error" not in r]
|
|
495
|
+
status = "ok" if rows and len(ok) == len(rows) else "partial_failure" if ok else "failed"
|
|
496
|
+
if rows and not ok:
|
|
497
|
+
notify_failure(self._config, "No stock could be analyzed.\n" + "\n".join(
|
|
498
|
+
f"{r['symbol']}: {r.get('error')}" for r in rows))
|
|
499
|
+
default = reports.get(prefs.language) or next(iter(reports.values()), None)
|
|
500
|
+
summary = default[1] if default else "No report produced."
|
|
501
|
+
cfg = self._store.load() # settings may have changed while the run went
|
|
502
|
+
cfg.last_run_at = datetime.now()
|
|
503
|
+
cfg.last_run_status = status
|
|
504
|
+
cfg.last_run_summary = summary
|
|
505
|
+
cfg.stock_count = len(targets)
|
|
506
|
+
self._store.save(cfg)
|
|
507
|
+
results = [{"ok": "error" not in r, "symbol": r["symbol"], "error": r.get("error")} for r in rows]
|
|
508
|
+
return {"status": status, "results": results, "summary": summary, "report_token": token}
|
|
509
|
+
|
|
510
|
+
def _run_one_symbol(self, symbol: str, market: str, mode: str, sections: Optional[list] = None,
|
|
511
|
+
user_id: Optional[int] = None, shared: bool = False) -> dict:
|
|
512
|
+
if self._orchestrator_factory is not None:
|
|
513
|
+
# Test/injection path: in-process, no multiprocessing, so a
|
|
514
|
+
# fake orchestrator/backend can be swapped in cleanly.
|
|
515
|
+
try:
|
|
516
|
+
return analyze_symbol(
|
|
517
|
+
symbol, market, mode, self._config, orchestrator=self._orchestrator_factory(), sections=sections
|
|
518
|
+
)
|
|
519
|
+
except Exception as exc:
|
|
520
|
+
return {"ok": False, "symbol": symbol, "market": market, "error": str(exc)}
|
|
521
|
+
|
|
522
|
+
result_queue = self._mp_context.Queue()
|
|
523
|
+
llm_kwargs = _extract_llm_kwargs(self._config)
|
|
524
|
+
process = self._mp_context.Process(
|
|
525
|
+
target=_run_one_symbol_in_process,
|
|
526
|
+
args=(symbol, market, mode, llm_kwargs, result_queue, sections,
|
|
527
|
+
user_id if user_id is not None else tenant.current_user_id(), shared),
|
|
528
|
+
)
|
|
529
|
+
process.start()
|
|
530
|
+
process.join(timeout=self._watchdog_seconds)
|
|
531
|
+
|
|
532
|
+
if process.is_alive():
|
|
533
|
+
logger.warning(
|
|
534
|
+
"Scheduled run for %s exceeded watchdog (%.0fs), terminating", symbol, self._watchdog_seconds
|
|
535
|
+
)
|
|
536
|
+
process.terminate()
|
|
537
|
+
process.join(timeout=5.0)
|
|
538
|
+
return {
|
|
539
|
+
"ok": False,
|
|
540
|
+
"symbol": symbol,
|
|
541
|
+
"market": market,
|
|
542
|
+
"error": f"watchdog timeout after {self._watchdog_seconds:.0f}s",
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
return result_queue.get_nowait()
|
|
547
|
+
except Exception:
|
|
548
|
+
return {"ok": False, "symbol": symbol, "market": market, "error": "child process exited without a result"}
|