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:
@@ -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 { 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 (
|
||||
<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 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 (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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' && (
|
||||
<p className="text-[11px] text-red-400">
|
||||
⚠ 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 ? (
|
||||
<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 && (
|
||||
<details className="glass-sm p-3">
|
||||
@@ -259,15 +271,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
||||
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
||||
</summary>
|
||||
<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>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
||||
<SetupCard setup={shortSetup} 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} regime={regime} />
|
||||
</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[];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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() {
|
||||
</h1>
|
||||
</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 */}
|
||||
{(trades.isLoading || performance.isLoading) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
Reference in New Issue
Block a user