Add activation thresholds: qualified-signal defaults and views
Admin-configurable thresholds (min R:R, default 2.0; min confidence, default 70%) defining what counts as an actionable signal: - Admin Settings: new Activation Thresholds panel (GET/PUT /admin/settings/activation) - GET /trades/activation exposes values to all users with access - Signals/Setups: filters initialize from activation values - Track Record: "Qualified signals only" toggle (default on) via min_rr/min_confidence params on /trades/performance; the confidence breakdown always covers the full population so the thresholds can be validated against outcomes - Dashboard: "Qualified" metric and qualified-first Top Setups - Outcome evaluator unchanged: every setup is still evaluated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.dependencies import get_db, require_admin
|
from app.dependencies import get_db, require_admin
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import (
|
||||||
|
ActivationConfigUpdate,
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
DataCleanupRequest,
|
DataCleanupRequest,
|
||||||
JobToggle,
|
JobToggle,
|
||||||
@@ -148,6 +149,28 @@ async def update_recommendation_settings(
|
|||||||
return APIEnvelope(status="success", data=updated)
|
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)
|
@router.get("/admin/settings/ticker-universe", response_model=APIEnvelope)
|
||||||
async def get_ticker_universe_setting(
|
async def get_ticker_universe_setting(
|
||||||
_admin: User = Depends(require_admin),
|
_admin: User = Depends(require_admin),
|
||||||
|
|||||||
+24
-1
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.dependencies import get_db, require_access
|
from app.dependencies import get_db, require_access
|
||||||
from app.schemas.common import APIEnvelope
|
from app.schemas.common import APIEnvelope
|
||||||
from app.schemas.trade_setup import RecommendationSummaryResponse, TradeSetupResponse
|
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.outcome_service import get_performance_stats
|
||||||
from app.services.rr_scanner_service import get_trade_setup_history, get_trade_setups
|
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)
|
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)
|
@router.get("/trades/performance", response_model=APIEnvelope)
|
||||||
async def get_trade_performance(
|
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),
|
_user=Depends(require_access),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> APIEnvelope:
|
) -> APIEnvelope:
|
||||||
@@ -58,8 +78,11 @@ async def get_trade_performance(
|
|||||||
|
|
||||||
Outcomes are written by the nightly outcome_evaluator job (win = target
|
Outcomes are written by the nightly outcome_evaluator job (win = target
|
||||||
hit first, loss = stop hit first, expired = neither within the window).
|
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)
|
return APIEnvelope(status="success", data=stats)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,9 @@ class RecommendationConfigUpdate(BaseModel):
|
|||||||
|
|
||||||
class TickerUniverseUpdate(BaseModel):
|
class TickerUniverseUpdate(BaseModel):
|
||||||
universe: Literal["sp500", "nasdaq100", "nasdaq_all"]
|
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)
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ RECOMMENDATION_CONFIG_DEFAULTS: dict[str, float] = {
|
|||||||
DEFAULT_TICKER_UNIVERSE = "sp500"
|
DEFAULT_TICKER_UNIVERSE = "sp500"
|
||||||
SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
|
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
|
# User management
|
||||||
@@ -143,6 +153,48 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
|
|||||||
return setting
|
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:
|
def _recommendation_public_to_storage_key(key: str) -> str:
|
||||||
return f"recommendation_{key}"
|
return f"recommendation_{key}"
|
||||||
|
|
||||||
|
|||||||
@@ -178,12 +178,20 @@ def _confidence_bucket(score: float | None) -> str | None:
|
|||||||
return 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.
|
"""Aggregate outcome statistics over all evaluated trade setups.
|
||||||
|
|
||||||
avg_r is the expectancy per trade in R-multiples (win = +rr_ratio,
|
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
|
loss = -1R, expired = 0R). A positive avg_r means the signals have
|
||||||
been profitable on a risk-adjusted basis.
|
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(
|
result = await db.execute(
|
||||||
select(TradeSetup).where(TradeSetup.actual_outcome.is_not(None))
|
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())
|
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_direction: dict[str, list[TradeSetup]] = {}
|
||||||
by_action: dict[str, list[TradeSetup]] = {}
|
by_action: dict[str, list[TradeSetup]] = {}
|
||||||
by_confidence: 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)
|
by_direction.setdefault(setup.direction, []).append(setup)
|
||||||
action = setup.recommended_action or "NONE"
|
action = setup.recommended_action or "NONE"
|
||||||
by_action.setdefault(action, []).append(setup)
|
by_action.setdefault(action, []).append(setup)
|
||||||
|
|
||||||
|
# Confidence buckets always cover the full evaluated population
|
||||||
|
for setup in evaluated:
|
||||||
bucket = _confidence_bucket(setup.confidence_score)
|
bucket = _confidence_bucket(setup.confidence_score)
|
||||||
if bucket is not None:
|
if bucket is not None:
|
||||||
by_confidence.setdefault(bucket, []).append(setup)
|
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]
|
bucket_order = [label for label, _, _ in _CONFIDENCE_BUCKETS]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"overall": _bucket_stats(evaluated),
|
"overall": _bucket_stats(qualified),
|
||||||
"pending": pending_count,
|
"pending": pending_count,
|
||||||
"by_direction": {k: _bucket_stats(v) for k, v in sorted(by_direction.items())},
|
"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())},
|
"by_action": {k: _bucket_stats(v) for k, v in sorted(by_action.items())},
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import type { ActivationConfig } from '../lib/types';
|
||||||
|
|
||||||
|
export function getActivation() {
|
||||||
|
return apiClient.get<ActivationConfig>('trades/activation').then((r) => r.data);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type {
|
import type {
|
||||||
|
ActivationConfig,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
PipelineReadiness,
|
PipelineReadiness,
|
||||||
RecommendationConfig,
|
RecommendationConfig,
|
||||||
@@ -68,6 +69,18 @@ export function updateRecommendationSettings(payload: Partial<RecommendationConf
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getActivationSettings() {
|
||||||
|
return apiClient
|
||||||
|
.get<ActivationConfig>('admin/settings/activation')
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateActivationSettings(payload: Partial<ActivationConfig>) {
|
||||||
|
return apiClient
|
||||||
|
.put<ActivationConfig>('admin/settings/activation', payload)
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
export function getTickerUniverseSetting() {
|
export function getTickerUniverseSetting() {
|
||||||
return apiClient
|
return apiClient
|
||||||
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
|
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type { PerformanceStats } from '../lib/types';
|
import type { PerformanceStats } from '../lib/types';
|
||||||
|
|
||||||
export function getPerformance() {
|
export interface PerformanceParams {
|
||||||
return apiClient.get<PerformanceStats>('trades/performance').then((r) => r.data);
|
min_rr?: number;
|
||||||
|
min_confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPerformance(params?: PerformanceParams) {
|
||||||
|
return apiClient
|
||||||
|
.get<PerformanceStats>('trades/performance', { params })
|
||||||
|
.then((r) => r.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ActivationConfig>(DEFAULTS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) setForm(data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
update.mutate(form as unknown as Record<string, number>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
setForm(DEFAULTS);
|
||||||
|
update.mutate(DEFAULTS as unknown as Record<string, number>);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <SkeletonTable rows={2} cols={2} />;
|
||||||
|
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load activation thresholds'}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-200">Activation Thresholds</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
value={form.min_rr}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
|
||||||
|
className="w-full input-glass px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-gray-400">Min Confidence (%)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={form.min_confidence}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, min_confidence: Number(e.target.value) }))}
|
||||||
|
className="w-full input-glass px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
|
||||||
|
{update.isPending ? 'Saving…' : 'Save Thresholds'}
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white" onClick={onReset} disabled={update.isPending}>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useActivation } from '../../hooks/useActivation';
|
||||||
import { useTrades } from '../../hooks/useTrades';
|
import { useTrades } from '../../hooks/useTrades';
|
||||||
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
|
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
|
||||||
import { SkeletonTable } from '../ui/Skeleton';
|
import { SkeletonTable } from '../ui/Skeleton';
|
||||||
@@ -94,16 +95,22 @@ function sortTrades(
|
|||||||
|
|
||||||
export function SetupsPanel() {
|
export function SetupsPanel() {
|
||||||
const { data: trades, isLoading, isError, error } = useTrades();
|
const { data: trades, isLoading, isError, error } = useTrades();
|
||||||
|
const activation = useActivation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
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<number | null>(null);
|
||||||
|
const [minConfidenceOverride, setMinConfidenceOverride] = useState<number | null>(null);
|
||||||
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
|
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
|
||||||
const [minConfidence, setMinConfidence] = useState(0);
|
|
||||||
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
|
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
|
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||||
|
|
||||||
|
const minRR = minRROverride ?? activation.data?.min_rr ?? 0;
|
||||||
|
const minConfidence = minConfidenceOverride ?? activation.data?.min_confidence ?? 0;
|
||||||
|
|
||||||
const scanMutation = useMutation({
|
const scanMutation = useMutation({
|
||||||
mutationFn: () => triggerJob('rr_scanner'),
|
mutationFn: () => triggerJob('rr_scanner'),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -143,7 +150,7 @@ export function SetupsPanel() {
|
|||||||
min={0}
|
min={0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={minRR}
|
value={minRR}
|
||||||
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
|
onChange={(e) => setMinRROverride(Number(e.target.value) || 0)}
|
||||||
className="w-20"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,7 +174,7 @@ export function SetupsPanel() {
|
|||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
value={minConfidence}
|
value={minConfidence}
|
||||||
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
|
onChange={(e) => setMinConfidenceOverride(Number(e.target.value) || 0)}
|
||||||
className="w-24"
|
className="w-24"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useActivation } from '../../hooks/useActivation';
|
||||||
import { usePerformance } from '../../hooks/usePerformance';
|
import { usePerformance } from '../../hooks/usePerformance';
|
||||||
import { triggerJob } from '../../api/admin';
|
import { triggerJob } from '../../api/admin';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
@@ -89,7 +91,14 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TrackRecordPanel() {
|
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 queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -106,6 +115,26 @@ export function TrackRecordPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="glass-sm flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={qualifiedOnly}
|
||||||
|
onChange={(e) => setQualifiedOnly(e.target.checked)}
|
||||||
|
className="h-4 w-4 cursor-pointer accent-blue-400"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Qualified signals only
|
||||||
|
{activation.data && (
|
||||||
|
<span className="num ml-2 text-xs text-gray-500">
|
||||||
|
R:R ≥ {activation.data.min_rr.toFixed(1)} · conf ≥ {activation.data.min_confidence.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500">Confidence breakdown always covers all setups.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<Disclosure summary="How outcomes are measured">
|
<Disclosure summary="How outcomes are measured">
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
@@ -138,8 +167,9 @@ export function TrackRecordPanel() {
|
|||||||
|
|
||||||
{data && data.overall.total === 0 && (
|
{data && data.overall.total === 0 && (
|
||||||
<Callout variant="empty">
|
<Callout variant="empty">
|
||||||
No evaluated setups yet. Outcomes appear once setups are old enough for their stop or
|
{qualifiedOnly
|
||||||
target to be hit — the evaluator runs nightly, or click Evaluate Now.
|
? '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.`}
|
{data.pending > 0 && ` ${data.pending} setup${data.pending === 1 ? '' : 's'} pending evaluation.`}
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, number>) =>
|
||||||
|
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() {
|
export function useTickerUniverseSetting() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'ticker-universe'],
|
queryKey: ['admin', 'ticker-universe'],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
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({
|
return useQuery({
|
||||||
queryKey: ['performance'],
|
queryKey: ['performance', params ?? null],
|
||||||
queryFn: getPerformance,
|
queryFn: () => getPerformance(params),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ export interface PerformanceStats {
|
|||||||
by_confidence: Record<string, OutcomeBucketStats>;
|
by_confidence: Record<string, OutcomeBucketStats>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activation thresholds: what counts as an actionable signal
|
||||||
|
export interface ActivationConfig {
|
||||||
|
min_rr: number;
|
||||||
|
min_confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TradeTarget {
|
export interface TradeTarget {
|
||||||
price: number;
|
price: number;
|
||||||
distance_from_entry: number;
|
distance_from_entry: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { ActivationSettings } from '../components/admin/ActivationSettings';
|
||||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||||
import { JobControls } from '../components/admin/JobControls';
|
import { JobControls } from '../components/admin/JobControls';
|
||||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||||
@@ -28,6 +29,7 @@ export default function AdminPage() {
|
|||||||
{activeTab === 'Tickers' && <TickerManagement />}
|
{activeTab === 'Tickers' && <TickerManagement />}
|
||||||
{activeTab === 'Settings' && (
|
{activeTab === 'Settings' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<ActivationSettings />
|
||||||
<TickerUniverseBootstrap />
|
<TickerUniverseBootstrap />
|
||||||
<RecommendationSettings />
|
<RecommendationSettings />
|
||||||
<SettingsForm />
|
<SettingsForm />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
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';
|
||||||
@@ -51,16 +52,25 @@ function DirectionTag({ direction }: { direction: string }) {
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const trades = useTrades();
|
const trades = useTrades();
|
||||||
const watchlist = useWatchlist();
|
const watchlist = useWatchlist();
|
||||||
|
const activation = useActivation();
|
||||||
const performance = usePerformance();
|
const performance = usePerformance();
|
||||||
|
|
||||||
const topSetups: TradeSetup[] = useMemo(
|
const minRR = activation.data?.min_rr ?? 2;
|
||||||
() => (trades.data ?? []).slice(0, 5),
|
const minConfidence = activation.data?.min_confidence ?? 70;
|
||||||
[trades.data],
|
|
||||||
|
const qualifiedSetups = useMemo(
|
||||||
|
() =>
|
||||||
|
(trades.data ?? []).filter(
|
||||||
|
(t) => t.rr_ratio >= minRR && (t.confidence_score ?? 0) >= minConfidence,
|
||||||
|
),
|
||||||
|
[trades.data, minRR, minConfidence],
|
||||||
);
|
);
|
||||||
|
|
||||||
const highConfidenceCount = useMemo(
|
// Show qualified setups first; fall back to the full list when none qualify
|
||||||
() => (trades.data ?? []).filter((t) => (t.confidence_score ?? 0) >= 70).length,
|
const showingQualified = qualifiedSetups.length > 0;
|
||||||
[trades.data],
|
const topSetups: TradeSetup[] = useMemo(
|
||||||
|
() => (showingQualified ? qualifiedSetups : trades.data ?? []).slice(0, 5),
|
||||||
|
[showingQualified, qualifiedSetups, trades.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const topWatchlist = useMemo(
|
const topWatchlist = useMemo(
|
||||||
@@ -100,10 +110,10 @@ export default function DashboardPage() {
|
|||||||
sub="latest per ticker & direction"
|
sub="latest per ticker & direction"
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
label="High Confidence"
|
label="Qualified"
|
||||||
value={String(highConfidenceCount)}
|
value={String(qualifiedSetups.length)}
|
||||||
sub="confidence ≥ 70%"
|
sub={`R:R ≥ ${minRR.toFixed(1)} & conf ≥ ${minConfidence.toFixed(0)}%`}
|
||||||
valueClass={highConfidenceCount > 0 ? 'text-blue-300' : 'text-gray-100'}
|
valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
label="Hit Rate"
|
label="Hit Rate"
|
||||||
@@ -122,7 +132,7 @@ export default function DashboardPage() {
|
|||||||
<div className="grid gap-8 xl:grid-cols-5">
|
<div className="grid gap-8 xl:grid-cols-5">
|
||||||
{/* Top setups */}
|
{/* Top setups */}
|
||||||
<div className="xl:col-span-3">
|
<div className="xl:col-span-3">
|
||||||
<Section title="Top Setups" hint="by confidence">
|
<Section title="Top Setups" hint={showingQualified ? 'qualified, by confidence' : 'none qualified — showing all'}>
|
||||||
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
|
||||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||||
{trades.data && topSetups.length === 0 && (
|
{trades.data && topSetups.length === 0 && (
|
||||||
|
|||||||
@@ -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"}
|
{"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"}
|
||||||
@@ -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})
|
||||||
@@ -270,3 +270,42 @@ class TestGetPerformanceStats:
|
|||||||
assert stats["overall"]["losses"] == 1
|
assert stats["overall"]["losses"] == 1
|
||||||
assert stats["overall"]["hit_rate"] == 0.0
|
assert stats["overall"]["hit_rate"] == 0.0
|
||||||
assert stats["overall"]["avg_r"] == -1.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
|
||||||
|
|||||||
Reference in New Issue
Block a user