diff --git a/app/routers/admin.py b/app/routers/admin.py index f76d31f..1220665 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db, require_admin from app.models.user import User from app.schemas.admin import ( + ActivationConfigUpdate, CreateUserRequest, DataCleanupRequest, JobToggle, @@ -148,6 +149,28 @@ async def update_recommendation_settings( return APIEnvelope(status="success", data=updated) +@router.get("/admin/settings/activation", response_model=APIEnvelope) +async def get_activation_settings( + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + config = await admin_service.get_activation_config(db) + return APIEnvelope(status="success", data=config) + + +@router.put("/admin/settings/activation", response_model=APIEnvelope) +async def update_activation_settings( + body: ActivationConfigUpdate, + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + updated = await admin_service.update_activation_config( + db, + body.model_dump(exclude_unset=True, exclude_none=True), + ) + return APIEnvelope(status="success", data=updated) + + @router.get("/admin/settings/ticker-universe", response_model=APIEnvelope) async def get_ticker_universe_setting( _admin: User = Depends(require_admin), diff --git a/app/routers/trades.py b/app/routers/trades.py index 8481b2e..0f76ebe 100644 --- a/app/routers/trades.py +++ b/app/routers/trades.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db, require_access from app.schemas.common import APIEnvelope from app.schemas.trade_setup import RecommendationSummaryResponse, TradeSetupResponse +from app.services import admin_service from app.services.outcome_service import get_performance_stats from app.services.rr_scanner_service import get_trade_setup_history, get_trade_setups @@ -49,8 +50,27 @@ async def list_trade_setups( return APIEnvelope(status="success", data=data) +@router.get("/trades/activation", response_model=APIEnvelope) +async def get_activation_thresholds( + _user=Depends(require_access), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Activation thresholds (min R:R, min confidence) for actionable signals. + + Readable by any user with access — drives Signals-page default filters + and the Dashboard's qualified-setup metrics. Configured by admins via + PUT /admin/settings/activation. + """ + config = await admin_service.get_activation_config(db) + return APIEnvelope(status="success", data=config) + + @router.get("/trades/performance", response_model=APIEnvelope) async def get_trade_performance( + min_rr: float | None = Query(None, ge=0, description="Only setups with R:R >= this"), + min_confidence: float | None = Query( + None, ge=0, le=100, description="Only setups with confidence >= this" + ), _user=Depends(require_access), db: AsyncSession = Depends(get_db), ) -> APIEnvelope: @@ -58,8 +78,11 @@ async def get_trade_performance( Outcomes are written by the nightly outcome_evaluator job (win = target hit first, loss = stop hit first, expired = neither within the window). + Optional min_rr / min_confidence filters apply to the overall, direction + and action breakdowns; the confidence breakdown always covers all setups + so thresholds can be validated against it. """ - stats = await get_performance_stats(db) + stats = await get_performance_stats(db, min_rr=min_rr, min_confidence=min_confidence) return APIEnvelope(status="success", data=stats) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index 6caf6ae..7a8f95a 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -56,3 +56,9 @@ class RecommendationConfigUpdate(BaseModel): class TickerUniverseUpdate(BaseModel): universe: Literal["sp500", "nasdaq100", "nasdaq_all"] + + +class ActivationConfigUpdate(BaseModel): + """Activation thresholds: what counts as an actionable signal.""" + min_rr: float | None = Field(default=None, ge=0) + min_confidence: float | None = Field(default=None, ge=0, le=100) diff --git a/app/services/admin_service.py b/app/services/admin_service.py index aa10319..0c47656 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -31,6 +31,16 @@ RECOMMENDATION_CONFIG_DEFAULTS: dict[str, float] = { DEFAULT_TICKER_UNIVERSE = "sp500" SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"} +# Activation thresholds: what counts as a signal worth acting on. +# Used as Signals-page default filters, the Dashboard's qualified-setup +# metrics, and the Track Record's "qualified only" view. The outcome +# evaluator deliberately ignores these — every setup gets evaluated so the +# thresholds themselves can be validated against outcomes. +ACTIVATION_DEFAULTS: dict[str, float] = { + "activation_min_rr": 2.0, + "activation_min_confidence": 70.0, +} + # --------------------------------------------------------------------------- # User management @@ -143,6 +153,48 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin return setting +# --------------------------------------------------------------------------- +# Activation thresholds +# --------------------------------------------------------------------------- + +async def get_activation_config(db: AsyncSession) -> dict[str, float]: + """Return activation thresholds with public keys (min_rr, min_confidence).""" + result = await db.execute( + select(SystemSetting).where(SystemSetting.key.like("activation_%")) + ) + config = dict(ACTIVATION_DEFAULTS) + for setting in result.scalars().all(): + if setting.key in config: + try: + config[setting.key] = float(setting.value) + except (TypeError, ValueError): + pass + return { + "min_rr": config["activation_min_rr"], + "min_confidence": config["activation_min_confidence"], + } + + +async def update_activation_config( + db: AsyncSession, updates: dict[str, float] +) -> dict[str, float]: + """Update activation thresholds. Accepts public keys min_rr / min_confidence.""" + if "min_rr" in updates and updates["min_rr"] < 0: + raise ValidationError("min_rr must be >= 0") + if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100: + raise ValidationError("min_confidence must be between 0 and 100") + + key_map = { + "min_rr": "activation_min_rr", + "min_confidence": "activation_min_confidence", + } + for public_key, storage_key in key_map.items(): + if public_key in updates: + await update_setting(db, storage_key, str(float(updates[public_key]))) + + return await get_activation_config(db) + + def _recommendation_public_to_storage_key(key: str) -> str: return f"recommendation_{key}" diff --git a/app/services/outcome_service.py b/app/services/outcome_service.py index 0fecbcc..0ba4bb6 100644 --- a/app/services/outcome_service.py +++ b/app/services/outcome_service.py @@ -178,12 +178,20 @@ def _confidence_bucket(score: float | None) -> str | None: return None -async def get_performance_stats(db: AsyncSession) -> dict: +async def get_performance_stats( + db: AsyncSession, + min_rr: float | None = None, + min_confidence: float | None = None, +) -> dict: """Aggregate outcome statistics over all evaluated trade setups. avg_r is the expectancy per trade in R-multiples (win = +rr_ratio, loss = -1R, expired = 0R). A positive avg_r means the signals have been profitable on a risk-adjusted basis. + + min_rr / min_confidence filter the overall, direction and action + breakdowns. The confidence breakdown deliberately stays unfiltered: + it is the instrument for validating the thresholds themselves. """ result = await db.execute( select(TradeSetup).where(TradeSetup.actual_outcome.is_not(None)) @@ -195,14 +203,26 @@ async def get_performance_stats(db: AsyncSession) -> dict: ) pending_count = len(pending_result.scalars().all()) + def qualifies(setup: TradeSetup) -> bool: + if min_rr is not None and setup.rr_ratio < min_rr: + return False + if min_confidence is not None and (setup.confidence_score or 0.0) < min_confidence: + return False + return True + + qualified = [s for s in evaluated if qualifies(s)] + by_direction: dict[str, list[TradeSetup]] = {} by_action: dict[str, list[TradeSetup]] = {} by_confidence: dict[str, list[TradeSetup]] = {} - for setup in evaluated: + for setup in qualified: by_direction.setdefault(setup.direction, []).append(setup) action = setup.recommended_action or "NONE" by_action.setdefault(action, []).append(setup) + + # Confidence buckets always cover the full evaluated population + for setup in evaluated: bucket = _confidence_bucket(setup.confidence_score) if bucket is not None: by_confidence.setdefault(bucket, []).append(setup) @@ -210,7 +230,7 @@ async def get_performance_stats(db: AsyncSession) -> dict: bucket_order = [label for label, _, _ in _CONFIDENCE_BUCKETS] return { - "overall": _bucket_stats(evaluated), + "overall": _bucket_stats(qualified), "pending": pending_count, "by_direction": {k: _bucket_stats(v) for k, v in sorted(by_direction.items())}, "by_action": {k: _bucket_stats(v) for k, v in sorted(by_action.items())}, diff --git a/frontend/src/api/activation.ts b/frontend/src/api/activation.ts new file mode 100644 index 0000000..ceb2e69 --- /dev/null +++ b/frontend/src/api/activation.ts @@ -0,0 +1,6 @@ +import apiClient from './client'; +import type { ActivationConfig } from '../lib/types'; + +export function getActivation() { + return apiClient.get('trades/activation').then((r) => r.data); +} diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 5af5dd2..5fcabd8 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,5 +1,6 @@ import apiClient from './client'; import type { + ActivationConfig, AdminUser, PipelineReadiness, RecommendationConfig, @@ -68,6 +69,18 @@ export function updateRecommendationSettings(payload: Partial r.data); } +export function getActivationSettings() { + return apiClient + .get('admin/settings/activation') + .then((r) => r.data); +} + +export function updateActivationSettings(payload: Partial) { + return apiClient + .put('admin/settings/activation', payload) + .then((r) => r.data); +} + export function getTickerUniverseSetting() { return apiClient .get('admin/settings/ticker-universe') diff --git a/frontend/src/api/performance.ts b/frontend/src/api/performance.ts index 3f735ca..512a47c 100644 --- a/frontend/src/api/performance.ts +++ b/frontend/src/api/performance.ts @@ -1,6 +1,13 @@ import apiClient from './client'; import type { PerformanceStats } from '../lib/types'; -export function getPerformance() { - return apiClient.get('trades/performance').then((r) => r.data); +export interface PerformanceParams { + min_rr?: number; + min_confidence?: number; +} + +export function getPerformance(params?: PerformanceParams) { + return apiClient + .get('trades/performance', { params }) + .then((r) => r.data); } diff --git a/frontend/src/components/admin/ActivationSettings.tsx b/frontend/src/components/admin/ActivationSettings.tsx new file mode 100644 index 0000000..faa0edd --- /dev/null +++ b/frontend/src/components/admin/ActivationSettings.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react'; +import type { ActivationConfig } from '../../lib/types'; +import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/useAdmin'; +import { SkeletonTable } from '../ui/Skeleton'; + +const DEFAULTS: ActivationConfig = { + min_rr: 2, + min_confidence: 70, +}; + +export function ActivationSettings() { + const { data, isLoading, isError, error } = useActivationSettings(); + const update = useUpdateActivationSettings(); + + const [form, setForm] = useState(DEFAULTS); + + useEffect(() => { + if (data) setForm(data); + }, [data]); + + const onSave = () => { + update.mutate(form as unknown as Record); + }; + + const onReset = () => { + setForm(DEFAULTS); + update.mutate(DEFAULTS as unknown as Record); + }; + + if (isLoading) return ; + if (isError) return

{(error as Error)?.message || 'Failed to load activation thresholds'}

; + + return ( +
+
+

Activation Thresholds

+

+ What counts as a signal worth acting on. Used as the default Signals filters, the + Dashboard's qualified-setup metrics, and the Track Record's "qualified only" view. + All setups are still evaluated regardless, so these thresholds can be validated + against the confidence breakdown. +

+
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/signals/SetupsPanel.tsx b/frontend/src/components/signals/SetupsPanel.tsx index 35f69d4..7bffec9 100644 --- a/frontend/src/components/signals/SetupsPanel.tsx +++ b/frontend/src/components/signals/SetupsPanel.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useActivation } from '../../hooks/useActivation'; import { useTrades } from '../../hooks/useTrades'; import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable'; import { SkeletonTable } from '../ui/Skeleton'; @@ -94,16 +95,22 @@ function sortTrades( export function SetupsPanel() { const { data: trades, isLoading, isError, error } = useTrades(); + const activation = useActivation(); const queryClient = useQueryClient(); const toast = useToast(); - const [minRR, setMinRR] = useState(0); + // null = user hasn't touched the filter; falls back to admin-configured + // activation thresholds once loaded + const [minRROverride, setMinRROverride] = useState(null); + const [minConfidenceOverride, setMinConfidenceOverride] = useState(null); const [directionFilter, setDirectionFilter] = useState('both'); - const [minConfidence, setMinConfidence] = useState(0); const [actionFilter, setActionFilter] = useState('all'); const [sortColumn, setSortColumn] = useState('rr_ratio'); const [sortDirection, setSortDirection] = useState('desc'); + const minRR = minRROverride ?? activation.data?.min_rr ?? 0; + const minConfidence = minConfidenceOverride ?? activation.data?.min_confidence ?? 0; + const scanMutation = useMutation({ mutationFn: () => triggerJob('rr_scanner'), onSuccess: () => { @@ -143,7 +150,7 @@ export function SetupsPanel() { min={0} step={0.1} value={minRR} - onChange={(e) => setMinRR(Number(e.target.value) || 0)} + onChange={(e) => setMinRROverride(Number(e.target.value) || 0)} className="w-20" /> @@ -167,7 +174,7 @@ export function SetupsPanel() { max={100} step={1} value={minConfidence} - onChange={(e) => setMinConfidence(Number(e.target.value) || 0)} + onChange={(e) => setMinConfidenceOverride(Number(e.target.value) || 0)} className="w-24" /> diff --git a/frontend/src/components/signals/TrackRecordPanel.tsx b/frontend/src/components/signals/TrackRecordPanel.tsx index e0d7b80..caad951 100644 --- a/frontend/src/components/signals/TrackRecordPanel.tsx +++ b/frontend/src/components/signals/TrackRecordPanel.tsx @@ -1,4 +1,6 @@ +import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useActivation } from '../../hooks/useActivation'; import { usePerformance } from '../../hooks/usePerformance'; import { triggerJob } from '../../api/admin'; import { Button } from '../ui/Button'; @@ -89,7 +91,14 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: { } export function TrackRecordPanel() { - const { data, isLoading, isError, error } = usePerformance(); + const [qualifiedOnly, setQualifiedOnly] = useState(true); + const activation = useActivation(); + + const params = qualifiedOnly && activation.data + ? { min_rr: activation.data.min_rr, min_confidence: activation.data.min_confidence } + : undefined; + + const { data, isLoading, isError, error } = usePerformance(params); const queryClient = useQueryClient(); const toast = useToast(); @@ -106,6 +115,26 @@ export function TrackRecordPanel() { return (
+
+ +

Confidence breakdown always covers all setups.

+
+

@@ -138,8 +167,9 @@ export function TrackRecordPanel() { {data && data.overall.total === 0 && ( - No evaluated setups yet. Outcomes appear once setups are old enough for their stop or - target to be hit — the evaluator runs nightly, or click Evaluate Now. + {qualifiedOnly + ? 'No evaluated setups meet the activation thresholds yet. Untick "Qualified signals only" to see all evaluated setups, or wait for more outcomes.' + : 'No evaluated setups yet. Outcomes appear once setups are old enough for their stop or target to be hit — the evaluator runs nightly, or click Evaluate Now.'} {data.pending > 0 && ` ${data.pending} setup${data.pending === 1 ? '' : 's'} pending evaluation.`} )} diff --git a/frontend/src/hooks/useActivation.ts b/frontend/src/hooks/useActivation.ts new file mode 100644 index 0000000..6bfdcc0 --- /dev/null +++ b/frontend/src/hooks/useActivation.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { getActivation } from '../api/activation'; + +export function useActivation() { + return useQuery({ + queryKey: ['activation'], + queryFn: getActivation, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 4961818..479b102 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -114,6 +114,32 @@ export function useUpdateRecommendationSettings() { }); } +export function useActivationSettings() { + return useQuery({ + queryKey: ['admin', 'activation-settings'], + queryFn: () => adminApi.getActivationSettings(), + }); +} + +export function useUpdateActivationSettings() { + const qc = useQueryClient(); + const { addToast } = useToast(); + + return useMutation({ + mutationFn: (payload: Record) => + adminApi.updateActivationSettings(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'activation-settings'] }); + qc.invalidateQueries({ queryKey: ['activation'] }); + qc.invalidateQueries({ queryKey: ['performance'] }); + addToast('success', 'Activation thresholds updated'); + }, + onError: (error: Error) => { + addToast('error', error.message || 'Failed to update activation thresholds'); + }, + }); +} + export function useTickerUniverseSetting() { return useQuery({ queryKey: ['admin', 'ticker-universe'], diff --git a/frontend/src/hooks/usePerformance.ts b/frontend/src/hooks/usePerformance.ts index 17b48c2..628287e 100644 --- a/frontend/src/hooks/usePerformance.ts +++ b/frontend/src/hooks/usePerformance.ts @@ -1,9 +1,9 @@ import { useQuery } from '@tanstack/react-query'; -import { getPerformance } from '../api/performance'; +import { getPerformance, type PerformanceParams } from '../api/performance'; -export function usePerformance() { +export function usePerformance(params?: PerformanceParams) { return useQuery({ - queryKey: ['performance'], - queryFn: getPerformance, + queryKey: ['performance', params ?? null], + queryFn: () => getPerformance(params), }); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0c70328..367f83b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -152,6 +152,12 @@ export interface PerformanceStats { by_confidence: Record; } +// Activation thresholds: what counts as an actionable signal +export interface ActivationConfig { + min_rr: number; + min_confidence: number; +} + export interface TradeTarget { price: number; distance_from_entry: number; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 75a26f7..5d4f7af 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { ActivationSettings } from '../components/admin/ActivationSettings'; import { DataCleanup } from '../components/admin/DataCleanup'; import { JobControls } from '../components/admin/JobControls'; import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel'; @@ -28,6 +29,7 @@ export default function AdminPage() { {activeTab === 'Tickers' && } {activeTab === 'Settings' && (

+ diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index e4d2e11..0bfafde 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { Link } from 'react-router-dom'; +import { useActivation } from '../hooks/useActivation'; import { useTrades } from '../hooks/useTrades'; import { useWatchlist } from '../hooks/useWatchlist'; import { usePerformance } from '../hooks/usePerformance'; @@ -51,16 +52,25 @@ function DirectionTag({ direction }: { direction: string }) { export default function DashboardPage() { const trades = useTrades(); const watchlist = useWatchlist(); + const activation = useActivation(); const performance = usePerformance(); - const topSetups: TradeSetup[] = useMemo( - () => (trades.data ?? []).slice(0, 5), - [trades.data], + const minRR = activation.data?.min_rr ?? 2; + const minConfidence = activation.data?.min_confidence ?? 70; + + const qualifiedSetups = useMemo( + () => + (trades.data ?? []).filter( + (t) => t.rr_ratio >= minRR && (t.confidence_score ?? 0) >= minConfidence, + ), + [trades.data, minRR, minConfidence], ); - const highConfidenceCount = useMemo( - () => (trades.data ?? []).filter((t) => (t.confidence_score ?? 0) >= 70).length, - [trades.data], + // Show qualified setups first; fall back to the full list when none qualify + const showingQualified = qualifiedSetups.length > 0; + const topSetups: TradeSetup[] = useMemo( + () => (showingQualified ? qualifiedSetups : trades.data ?? []).slice(0, 5), + [showingQualified, qualifiedSetups, trades.data], ); const topWatchlist = useMemo( @@ -100,10 +110,10 @@ export default function DashboardPage() { sub="latest per ticker & direction" /> 0 ? 'text-blue-300' : 'text-gray-100'} + label="Qualified" + value={String(qualifiedSetups.length)} + sub={`R:R ≥ ${minRR.toFixed(1)} & conf ≥ ${minConfidence.toFixed(0)}%`} + valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'} /> {/* Top setups */}
-
+
{trades.isLoading && } {trades.isError && Failed to load setups} {trades.data && topSetups.length === 0 && ( diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index b816b06..22c8b50 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/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/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/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.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/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/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.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/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/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/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.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/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/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/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 diff --git a/tests/unit/test_activation_settings.py b/tests/unit/test_activation_settings.py new file mode 100644 index 0000000..afb2af9 --- /dev/null +++ b/tests/unit/test_activation_settings.py @@ -0,0 +1,50 @@ +"""Unit tests for activation threshold configuration.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.exceptions import ValidationError +from app.services.admin_service import ( + get_activation_config, + update_activation_config, +) + + +@pytest.fixture +async def session() -> AsyncSession: + """DB session compatible with services that commit.""" + from tests.conftest import _test_session_factory + + async with _test_session_factory() as session: + yield session + + +class TestActivationConfig: + async def test_defaults_when_unset(self, session: AsyncSession): + config = await get_activation_config(session) + assert config == {"min_rr": 2.0, "min_confidence": 70.0} + + async def test_update_and_read_back(self, session: AsyncSession): + updated = await update_activation_config( + session, {"min_rr": 1.5, "min_confidence": 60.0} + ) + assert updated == {"min_rr": 1.5, "min_confidence": 60.0} + + config = await get_activation_config(session) + assert config == {"min_rr": 1.5, "min_confidence": 60.0} + + async def test_partial_update_keeps_other_value(self, session: AsyncSession): + await update_activation_config(session, {"min_confidence": 80.0}) + config = await get_activation_config(session) + assert config["min_rr"] == 2.0 # default untouched + assert config["min_confidence"] == 80.0 + + async def test_rejects_negative_rr(self, session: AsyncSession): + with pytest.raises(ValidationError): + await update_activation_config(session, {"min_rr": -1.0}) + + async def test_rejects_out_of_range_confidence(self, session: AsyncSession): + with pytest.raises(ValidationError): + await update_activation_config(session, {"min_confidence": 120.0}) diff --git a/tests/unit/test_outcome_service.py b/tests/unit/test_outcome_service.py index 3852465..cd31d7f 100644 --- a/tests/unit/test_outcome_service.py +++ b/tests/unit/test_outcome_service.py @@ -270,3 +270,42 @@ class TestGetPerformanceStats: assert stats["overall"]["losses"] == 1 assert stats["overall"]["hit_rate"] == 0.0 assert stats["overall"]["avg_r"] == -1.0 + + async def test_activation_filters_apply_to_overall_but_not_confidence( + self, db_session: AsyncSession + ): + ticker = await _make_ticker(db_session) + # Qualified: high confidence, high R:R + db_session.add(_make_setup( + ticker, rr=3.0, confidence_score=80.0, actual_outcome=OUTCOME_TARGET_HIT, + )) + # Unqualified: low confidence + db_session.add(_make_setup( + ticker, rr=3.0, confidence_score=40.0, actual_outcome=OUTCOME_STOP_HIT, + )) + # Unqualified: low R:R + db_session.add(_make_setup( + ticker, rr=1.2, confidence_score=90.0, actual_outcome=OUTCOME_STOP_HIT, + )) + await db_session.flush() + + stats = await get_performance_stats(db_session, min_rr=2.0, min_confidence=70.0) + + # Overall covers only the qualified setup + assert stats["overall"]["total"] == 1 + assert stats["overall"]["wins"] == 1 + assert stats["overall"]["hit_rate"] == 100.0 + + # Confidence breakdown still covers the full population + total_in_confidence = sum( + bucket["total"] for bucket in stats["by_confidence"].values() + ) + assert total_in_confidence == 3 + + async def test_no_filters_returns_full_population(self, db_session: AsyncSession): + ticker = await _make_ticker(db_session) + db_session.add(_make_setup(ticker, rr=1.2, confidence_score=10.0, actual_outcome=OUTCOME_TARGET_HIT)) + await db_session.flush() + + stats = await get_performance_stats(db_session) + assert stats["overall"]["total"] == 1