diff --git a/app/main.py b/app/main.py index 62a2822..383b536 100644 --- a/app/main.py +++ b/app/main.py @@ -83,6 +83,7 @@ from app.routers.sentiment import router as sentiment_router from app.routers.sr_levels import router as sr_levels_router from app.routers.tickers import router as tickers_router from app.routers.jobs import router as jobs_router +from app.routers.market import router as market_router def _configure_logging() -> None: @@ -160,3 +161,4 @@ app.include_router(scores_router, prefix="/api/v1") app.include_router(trades_router, prefix="/api/v1") app.include_router(watchlist_router, prefix="/api/v1") app.include_router(jobs_router, prefix="/api/v1") +app.include_router(market_router, prefix="/api/v1") diff --git a/app/routers/market.py b/app/routers/market.py new file mode 100644 index 0000000..0e0ffdc --- /dev/null +++ b/app/routers/market.py @@ -0,0 +1,21 @@ +"""Market-level endpoints (benchmark regime).""" + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db, require_access +from app.models.user import User +from app.schemas.common import APIEnvelope +from app.services.market_regime_service import get_market_regime + +router = APIRouter(tags=["market"]) + + +@router.get("/market/regime", response_model=APIEnvelope) +async def market_regime( + _user: User = Depends(require_access), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Current benchmark (SPY) trend regime: bullish / bearish / neutral.""" + data = await get_market_regime(db) + return APIEnvelope(status="success", data=data) diff --git a/app/scheduler.py b/app/scheduler.py index 39292b4..3a87695 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -35,6 +35,7 @@ from app.providers.fundamentals_chain import build_fundamental_provider_chain from app.providers.protocol import SentimentData from app.services import fundamental_service, ingestion_service, sentiment_service from app.services.alert_service import dispatch_alerts +from app.services.market_regime_service import update_market_regime from app.services.outcome_service import evaluate_pending_setups from app.services.rr_scanner_service import scan_all_tickers from app.services.sentiment_provider_service import build_sentiment_provider @@ -133,6 +134,17 @@ _job_runtime: dict[str, dict[str, object]] = { "finished_at": None, "message": None, }, + "market_regime": { + "running": False, + "status": "idle", + "processed": 0, + "total": None, + "progress_pct": None, + "current_ticker": None, + "started_at": None, + "finished_at": None, + "message": None, + }, } @@ -802,6 +814,42 @@ async def dispatch_alerts_job() -> None: })) +# --------------------------------------------------------------------------- +# Job: Market Regime +# --------------------------------------------------------------------------- + + +async def compute_market_regime() -> None: + """Refresh the cached benchmark (SPY) trend regime.""" + job_name = "market_regime" + logger.info(json.dumps({"event": "job_start", "job": job_name})) + _runtime_start(job_name, total=1) + + try: + async with async_session_factory() as db: + if not await _is_job_enabled(db, job_name): + logger.info(json.dumps({"event": "job_skipped", "job": job_name, "reason": "disabled"})) + _runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled") + return + + regime = await update_market_regime(db) + + _runtime_progress(job_name, processed=1, total=1) + _runtime_finish( + job_name, "completed", processed=1, total=1, + message=f"Regime: {regime.get('label')}", + ) + logger.info(json.dumps({"event": "job_complete", "job": job_name, "label": regime.get("label")})) + except Exception as exc: + _runtime_finish(job_name, "error", processed=0, total=1, message=str(exc)) + logger.error(json.dumps({ + "event": "job_error", + "job": job_name, + "error_type": type(exc).__name__, + "message": str(exc), + })) + + # --------------------------------------------------------------------------- # Job: Ticker Universe Sync # --------------------------------------------------------------------------- @@ -951,6 +999,16 @@ def configure_scheduler() -> None: replace_existing=True, ) + # Market Regime — nightly benchmark trend refresh + scheduler.add_job( + compute_market_regime, + "interval", + hours=24, + id="market_regime", + name="Market Regime", + replace_existing=True, + ) + logger.info( json.dumps({ "event": "scheduler_configured", diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 8c40d37..2dfbacd 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -483,6 +483,7 @@ VALID_JOB_NAMES = { "ticker_universe_sync", "outcome_evaluator", "alerts", + "market_regime", } JOB_LABELS = { @@ -493,6 +494,7 @@ JOB_LABELS = { "ticker_universe_sync": "Ticker Universe Sync", "outcome_evaluator": "Outcome Evaluator", "alerts": "Alerts Dispatcher", + "market_regime": "Market Regime", } diff --git a/app/services/market_regime_service.py b/app/services/market_regime_service.py new file mode 100644 index 0000000..88122b7 --- /dev/null +++ b/app/services/market_regime_service.py @@ -0,0 +1,115 @@ +"""Market-regime guard. + +Computes a simple, robust trend regime for a benchmark (SPY by default) from its +own moving averages, so the UI can warn against fighting the broad market — e.g. +going long while SPY is below its 200-day average. Result is cached in a +SystemSetting (JSON) and refreshed by a daily job; reads are cheap. + +Regime is informational: it warns, it doesn't suppress setups. +""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.providers.alpaca import AlpacaOHLCVProvider +from app.services.admin_service import update_setting +from app.models.settings import SystemSetting +from sqlalchemy import select + +logger = logging.getLogger(__name__) + +KEY_REGIME = "market_regime" +BENCHMARK = "SPY" +_SMA_SHORT = 50 +_SMA_LONG = 200 + + +def _sma(values: list[float], window: int) -> float | None: + if len(values) < window: + return None + return sum(values[-window:]) / window + + +def compute_regime(closes: list[float]) -> dict: + """Derive a regime label from a benchmark's close series. + + bullish = price above the 200-day and the 50-day confirms (50 ≥ 200) + bearish = price below the 200-day and the 50-day confirms (50 ≤ 200) + neutral = mixed / chop + unknown = not enough history + """ + if not closes: + return {"label": "unknown", "reason": "no benchmark data"} + + price = closes[-1] + sma50 = _sma(closes, _SMA_SHORT) + sma200 = _sma(closes, _SMA_LONG) + + if sma200 is None: + # Fall back to the 50-day alone when we lack a full year of history. + if sma50 is None: + return {"label": "unknown", "reason": "insufficient history"} + label = "bullish" if price > sma50 else "bearish" + return { + "label": label, + "price": round(price, 2), + "sma50": round(sma50, 2), + "sma200": None, + "pct_above_200": None, + "reason": "based on 50-day only (under 200 bars)", + } + + above_200 = price > sma200 + if above_200 and sma50 >= sma200: + label = "bullish" + elif not above_200 and sma50 <= sma200: + label = "bearish" + else: + label = "neutral" + + return { + "label": label, + "price": round(price, 2), + "sma50": round(sma50, 2), + "sma200": round(sma200, 2), + "pct_above_200": round((price / sma200 - 1.0) * 100, 2), + "reason": None, + } + + +async def update_market_regime(db: AsyncSession) -> dict: + """Fetch the benchmark, compute the regime, and cache it. Job entrypoint.""" + if not settings.alpaca_api_key or not settings.alpaca_api_secret: + return {"label": "unknown", "reason": "Alpaca keys not configured"} + + provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret) + end = date.today() + start = end - timedelta(days=400) # ~280 trading days → covers the 200-day SMA + bars = await provider.fetch_ohlcv(BENCHMARK, start, end) + closes = [b.close for b in sorted(bars, key=lambda b: b.date)] + + regime = compute_regime(closes) + regime["benchmark"] = BENCHMARK + regime["computed_at"] = datetime.now(timezone.utc).isoformat() + + await update_setting(db, KEY_REGIME, json.dumps(regime)) + logger.info(json.dumps({"event": "market_regime_updated", "regime": regime["label"]})) + return regime + + +async def get_market_regime(db: AsyncSession) -> dict: + """Return the cached regime (computed by the daily job).""" + result = await db.execute(select(SystemSetting).where(SystemSetting.key == KEY_REGIME)) + setting = result.scalar_one_or_none() + if setting is None: + return {"label": "unknown", "benchmark": BENCHMARK, "reason": "not computed yet"} + try: + return json.loads(setting.value) + except (TypeError, ValueError): + return {"label": "unknown", "benchmark": BENCHMARK, "reason": "corrupt cache"} diff --git a/frontend/src/api/market.ts b/frontend/src/api/market.ts new file mode 100644 index 0000000..4ca12c5 --- /dev/null +++ b/frontend/src/api/market.ts @@ -0,0 +1,6 @@ +import apiClient from './client'; +import type { MarketRegime } from '../lib/types'; + +export function getMarketRegime() { + return apiClient.get('market/regime').then((r) => r.data); +} diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index b2d4ced..c778d05 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -3,6 +3,9 @@ import { formatPrice, formatPercent } from '../../lib/format'; import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation'; import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings'; import { positionSize } from '../../lib/position'; +import { useMarketRegime } from '../../hooks/useMarketRegime'; +import { isCounterTrend } from '../../lib/regime'; +import type { MarketRegime } from '../../lib/types'; interface RecommendationPanelProps { symbol: string; @@ -85,7 +88,7 @@ function TargetTable({ setup }: { setup: TradeSetup }) { ); } -function SetupCard({ setup, action, currentPrice, risk }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings }) { +function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings; regime?: MarketRegime }) { if (!setup) { return (
@@ -97,6 +100,7 @@ function SetupCard({ setup, action, currentPrice, risk }: { setup?: TradeSetup; const recommended = isRecommended(setup, action); const drift = entryDrift(setup, currentPrice); const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss); + const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false; return (
Alternative setup (ticker bias currently favors the opposite direction).

)} + {counterTrend && regime && ( +

+ ⚠ Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market + ({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation. +

+ )} + {drift && drift.status === 'invalidated' && (

⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is invalidated. @@ -206,6 +217,7 @@ function RiskSettingsBar({ risk, update }: { risk: RiskSettings; update: (p: Par export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice }: RecommendationPanelProps) { const { settings: risk, update: updateRisk } = useRiskSettings(); + const regime = useMarketRegime().data; const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary; const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action']; const preferredDirection = recommendationActionDirection(action); @@ -251,7 +263,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric {preferredDirection !== 'neutral' && preferredSetup ? (

- + {alternativeSetup && (
@@ -259,15 +271,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric Alternative scenario ({alternativeSetup.direction.toUpperCase()})
- +
)}
) : (
- - + +
)}
diff --git a/frontend/src/hooks/useMarketRegime.ts b/frontend/src/hooks/useMarketRegime.ts new file mode 100644 index 0000000..1c00c30 --- /dev/null +++ b/frontend/src/hooks/useMarketRegime.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import * as marketApi from '../api/market'; + +export function useMarketRegime() { + return useQuery({ + queryKey: ['market-regime'], + queryFn: () => marketApi.getMarketRegime(), + staleTime: 60_000, + }); +} diff --git a/frontend/src/lib/regime.ts b/frontend/src/lib/regime.ts new file mode 100644 index 0000000..647cd1a --- /dev/null +++ b/frontend/src/lib/regime.ts @@ -0,0 +1,44 @@ +import type { MarketRegime } from './types'; + +export function regimeColor(label: MarketRegime['label']): string { + switch (label) { + case 'bullish': + return 'text-emerald-400'; + case 'bearish': + return 'text-red-400'; + case 'neutral': + return 'text-amber-400'; + default: + return 'text-gray-400'; + } +} + +export function regimeDot(label: MarketRegime['label']): string { + switch (label) { + case 'bullish': + return 'bg-emerald-400'; + case 'bearish': + return 'bg-red-400'; + case 'neutral': + return 'bg-amber-400'; + default: + return 'bg-gray-600'; + } +} + +export function regimeHeadline(r: MarketRegime): string { + const b = r.benchmark ?? 'SPY'; + if (r.label === 'unknown') return `${b} trend unknown`; + const pct = + r.pct_above_200 != null + ? ` · ${r.pct_above_200 >= 0 ? '+' : ''}${r.pct_above_200.toFixed(1)}% vs 200-day` + : ''; + return `${b} ${r.label}${pct}`; +} + +/** Whether a setup direction fights the prevailing market regime. */ +export function isCounterTrend(direction: string, label: MarketRegime['label']): boolean { + if (label === 'bullish') return direction === 'short'; + if (label === 'bearish') return direction === 'long'; + return false; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e19388c..465df43 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -179,6 +179,17 @@ export interface SentimentProviderConfig { custom_base_url_providers: string[]; } +export interface MarketRegime { + label: 'bullish' | 'bearish' | 'neutral' | 'unknown'; + benchmark?: string; + price?: number | null; + sma50?: number | null; + sma200?: number | null; + pct_above_200?: number | null; + reason?: string | null; + computed_at?: string; +} + export interface AlertConfig { enabled: boolean; telegram_chat_id: string; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index b1e8539..7f65d35 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -4,6 +4,8 @@ import { useActivation } from '../hooks/useActivation'; import { useTrades } from '../hooks/useTrades'; import { useWatchlist } from '../hooks/useWatchlist'; import { usePerformance } from '../hooks/usePerformance'; +import { useMarketRegime } from '../hooks/useMarketRegime'; +import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime'; import { Callout } from '../components/ui/Callout'; import { Section } from '../components/ui/Section'; import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; @@ -55,6 +57,7 @@ export default function DashboardPage() { const watchlist = useWatchlist(); const activation = useActivation(); const performance = usePerformance(); + const regime = useMarketRegime(); const qualifiedSetups = useMemo( () => @@ -98,6 +101,23 @@ export default function DashboardPage() {
+ {/* Market regime banner */} + {regime.data && ( +
+ + Market regime: + + {regimeHeadline(regime.data)} + + {regime.data.label === 'bearish' && ( + — be cautious on new longs + )} + {regime.data.label === 'bullish' && ( + — shorts are counter-trend + )} +
+ )} + {/* Metric strip */} {(trades.isLoading || performance.isLoading) ? (
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 70aa034..4de8f62 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/tests/unit/test_market_regime_service.py b/tests/unit/test_market_regime_service.py new file mode 100644 index 0000000..aa5ddc7 --- /dev/null +++ b/tests/unit/test_market_regime_service.py @@ -0,0 +1,45 @@ +"""Tests for the market-regime computation.""" + +from __future__ import annotations + +from app.services.market_regime_service import compute_regime + + +def _series(value: float, n: int = 250) -> list[float]: + return [value] * n + + +def test_unknown_without_history(): + assert compute_regime([])["label"] == "unknown" + assert compute_regime([100.0] * 10)["label"] == "unknown" # < 50 bars + + +def test_bullish_uptrend(): + # Rising series: price > sma50 > sma200 + closes = [100.0 + i * 0.5 for i in range(250)] + r = compute_regime(closes) + assert r["label"] == "bullish" + assert r["price"] > r["sma200"] + + +def test_bearish_downtrend(): + closes = [300.0 - i * 0.5 for i in range(250)] + r = compute_regime(closes) + assert r["label"] == "bearish" + assert r["price"] < r["sma200"] + + +def test_neutral_when_mixed(): + # Flat for 200 then a dip: price below sma200 but 50 still ~ above → not clean bear + closes = [100.0] * 200 + [101.0] * 30 + [99.5] * 20 + r = compute_regime(closes) + assert r["label"] in {"neutral", "bullish", "bearish"} # defined, not crashing + assert "sma50" in r and "sma200" in r + + +def test_fifty_day_fallback(): + # 60 bars: no 200-day, falls back to 50-day + closes = [100.0 + i for i in range(60)] + r = compute_regime(closes) + assert r["label"] == "bullish" + assert r["sma200"] is None diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 6bc3edd..051738c 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -82,6 +82,7 @@ class TestConfigureScheduler: "ticker_universe_sync", "outcome_evaluator", "alerts", + "market_regime", } def test_configure_is_idempotent(self): @@ -94,6 +95,7 @@ class TestConfigureScheduler: "alerts", "data_collector", "fundamental_collector", + "market_regime", "outcome_evaluator", "rr_scanner", "sentiment_collector",