add market-regime guard (SPY trend) — inform + warn
New market_regime_service computes a benchmark (SPY) trend from its 50/200-day SMAs, cached in a SystemSetting and refreshed by a nightly job; GET /market/regime exposes it. Dashboard shows a regime banner; setup cards flag a counter-trend caution when a setup fights the regime (LONG in a bearish market / SHORT in a bullish one). Informational only — nothing is suppressed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.sr_levels import router as sr_levels_router
|
||||||
from app.routers.tickers import router as tickers_router
|
from app.routers.tickers import router as tickers_router
|
||||||
from app.routers.jobs import router as jobs_router
|
from app.routers.jobs import router as jobs_router
|
||||||
|
from app.routers.market import router as market_router
|
||||||
|
|
||||||
|
|
||||||
def _configure_logging() -> None:
|
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(trades_router, prefix="/api/v1")
|
||||||
app.include_router(watchlist_router, prefix="/api/v1")
|
app.include_router(watchlist_router, prefix="/api/v1")
|
||||||
app.include_router(jobs_router, prefix="/api/v1")
|
app.include_router(jobs_router, prefix="/api/v1")
|
||||||
|
app.include_router(market_router, prefix="/api/v1")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -35,6 +35,7 @@ from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
|||||||
from app.providers.protocol import SentimentData
|
from app.providers.protocol import SentimentData
|
||||||
from app.services import fundamental_service, ingestion_service, sentiment_service
|
from app.services import fundamental_service, ingestion_service, sentiment_service
|
||||||
from app.services.alert_service import dispatch_alerts
|
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.outcome_service import evaluate_pending_setups
|
||||||
from app.services.rr_scanner_service import scan_all_tickers
|
from app.services.rr_scanner_service import scan_all_tickers
|
||||||
from app.services.sentiment_provider_service import build_sentiment_provider
|
from app.services.sentiment_provider_service import build_sentiment_provider
|
||||||
@@ -133,6 +134,17 @@ _job_runtime: dict[str, dict[str, object]] = {
|
|||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
"message": 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
|
# Job: Ticker Universe Sync
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -951,6 +999,16 @@ def configure_scheduler() -> None:
|
|||||||
replace_existing=True,
|
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(
|
logger.info(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"event": "scheduler_configured",
|
"event": "scheduler_configured",
|
||||||
|
|||||||
@@ -483,6 +483,7 @@ VALID_JOB_NAMES = {
|
|||||||
"ticker_universe_sync",
|
"ticker_universe_sync",
|
||||||
"outcome_evaluator",
|
"outcome_evaluator",
|
||||||
"alerts",
|
"alerts",
|
||||||
|
"market_regime",
|
||||||
}
|
}
|
||||||
|
|
||||||
JOB_LABELS = {
|
JOB_LABELS = {
|
||||||
@@ -493,6 +494,7 @@ JOB_LABELS = {
|
|||||||
"ticker_universe_sync": "Ticker Universe Sync",
|
"ticker_universe_sync": "Ticker Universe Sync",
|
||||||
"outcome_evaluator": "Outcome Evaluator",
|
"outcome_evaluator": "Outcome Evaluator",
|
||||||
"alerts": "Alerts Dispatcher",
|
"alerts": "Alerts Dispatcher",
|
||||||
|
"market_regime": "Market Regime",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import type { MarketRegime } from '../lib/types';
|
||||||
|
|
||||||
|
export function getMarketRegime() {
|
||||||
|
return apiClient.get<MarketRegime>('market/regime').then((r) => r.data);
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import { formatPrice, formatPercent } from '../../lib/format';
|
|||||||
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
|
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
|
||||||
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
|
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
|
||||||
import { positionSize } from '../../lib/position';
|
import { positionSize } from '../../lib/position';
|
||||||
|
import { useMarketRegime } from '../../hooks/useMarketRegime';
|
||||||
|
import { isCounterTrend } from '../../lib/regime';
|
||||||
|
import type { MarketRegime } from '../../lib/types';
|
||||||
|
|
||||||
interface RecommendationPanelProps {
|
interface RecommendationPanelProps {
|
||||||
symbol: string;
|
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) {
|
if (!setup) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-sm p-4 text-xs text-gray-500">
|
<div className="glass-sm p-4 text-xs text-gray-500">
|
||||||
@@ -97,6 +100,7 @@ function SetupCard({ setup, action, currentPrice, risk }: { setup?: TradeSetup;
|
|||||||
const recommended = isRecommended(setup, action);
|
const recommended = isRecommended(setup, action);
|
||||||
const drift = entryDrift(setup, currentPrice);
|
const drift = entryDrift(setup, currentPrice);
|
||||||
const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
|
const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
|
||||||
|
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -114,6 +118,13 @@ function SetupCard({ setup, action, currentPrice, risk }: { setup?: TradeSetup;
|
|||||||
<p className="text-[11px] text-amber-400">Alternative setup (ticker bias currently favors the opposite direction).</p>
|
<p className="text-[11px] text-amber-400">Alternative setup (ticker bias currently favors the opposite direction).</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{counterTrend && regime && (
|
||||||
|
<p className="text-[11px] text-amber-400">
|
||||||
|
⚠ Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market
|
||||||
|
({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{drift && drift.status === 'invalidated' && (
|
{drift && drift.status === 'invalidated' && (
|
||||||
<p className="text-[11px] text-red-400">
|
<p className="text-[11px] text-red-400">
|
||||||
⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is 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) {
|
export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice }: RecommendationPanelProps) {
|
||||||
const { settings: risk, update: updateRisk } = useRiskSettings();
|
const { settings: risk, update: updateRisk } = useRiskSettings();
|
||||||
|
const regime = useMarketRegime().data;
|
||||||
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
|
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
|
||||||
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
|
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
|
||||||
const preferredDirection = recommendationActionDirection(action);
|
const preferredDirection = recommendationActionDirection(action);
|
||||||
@@ -251,7 +263,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
|
|
||||||
{preferredDirection !== 'neutral' && preferredSetup ? (
|
{preferredDirection !== 'neutral' && preferredSetup ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
|
||||||
|
|
||||||
{alternativeSetup && (
|
{alternativeSetup && (
|
||||||
<details className="glass-sm p-3">
|
<details className="glass-sm p-3">
|
||||||
@@ -259,15 +271,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
|
||||||
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -179,6 +179,17 @@ export interface SentimentProviderConfig {
|
|||||||
custom_base_url_providers: string[];
|
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 {
|
export interface AlertConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
telegram_chat_id: string;
|
telegram_chat_id: string;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useActivation } from '../hooks/useActivation';
|
|||||||
import { useTrades } from '../hooks/useTrades';
|
import { useTrades } from '../hooks/useTrades';
|
||||||
import { useWatchlist } from '../hooks/useWatchlist';
|
import { useWatchlist } from '../hooks/useWatchlist';
|
||||||
import { usePerformance } from '../hooks/usePerformance';
|
import { usePerformance } from '../hooks/usePerformance';
|
||||||
|
import { useMarketRegime } from '../hooks/useMarketRegime';
|
||||||
|
import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
|
||||||
import { Callout } from '../components/ui/Callout';
|
import { Callout } from '../components/ui/Callout';
|
||||||
import { Section } from '../components/ui/Section';
|
import { Section } from '../components/ui/Section';
|
||||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||||
@@ -55,6 +57,7 @@ export default function DashboardPage() {
|
|||||||
const watchlist = useWatchlist();
|
const watchlist = useWatchlist();
|
||||||
const activation = useActivation();
|
const activation = useActivation();
|
||||||
const performance = usePerformance();
|
const performance = usePerformance();
|
||||||
|
const regime = useMarketRegime();
|
||||||
|
|
||||||
const qualifiedSetups = useMemo(
|
const qualifiedSetups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -98,6 +101,23 @@ export default function DashboardPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Market regime banner */}
|
||||||
|
{regime.data && (
|
||||||
|
<div className="glass-sm flex items-center gap-2.5 px-4 py-2.5">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${regimeDot(regime.data.label)}`} />
|
||||||
|
<span className="text-sm text-gray-400">Market regime:</span>
|
||||||
|
<span className={`text-sm font-semibold ${regimeColor(regime.data.label)}`}>
|
||||||
|
{regimeHeadline(regime.data)}
|
||||||
|
</span>
|
||||||
|
{regime.data.label === 'bearish' && (
|
||||||
|
<span className="text-xs text-gray-500">— be cautious on new longs</span>
|
||||||
|
)}
|
||||||
|
{regime.data.label === 'bullish' && (
|
||||||
|
<span className="text-xs text-gray-500">— shorts are counter-trend</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Metric strip */}
|
{/* Metric strip */}
|
||||||
{(trades.isLoading || performance.isLoading) ? (
|
{(trades.isLoading || performance.isLoading) ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|||||||
@@ -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"}
|
{"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"}
|
||||||
@@ -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
|
||||||
@@ -82,6 +82,7 @@ class TestConfigureScheduler:
|
|||||||
"ticker_universe_sync",
|
"ticker_universe_sync",
|
||||||
"outcome_evaluator",
|
"outcome_evaluator",
|
||||||
"alerts",
|
"alerts",
|
||||||
|
"market_regime",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_configure_is_idempotent(self):
|
def test_configure_is_idempotent(self):
|
||||||
@@ -94,6 +95,7 @@ class TestConfigureScheduler:
|
|||||||
"alerts",
|
"alerts",
|
||||||
"data_collector",
|
"data_collector",
|
||||||
"fundamental_collector",
|
"fundamental_collector",
|
||||||
|
"market_regime",
|
||||||
"outcome_evaluator",
|
"outcome_evaluator",
|
||||||
"rr_scanner",
|
"rr_scanner",
|
||||||
"sentiment_collector",
|
"sentiment_collector",
|
||||||
|
|||||||
Reference in New Issue
Block a user