Add multi-factor conviction gate to activation
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 26s

Make "qualified" mean an edge candidate, not just R:R + confidence.
The gate now also requires (all admin-configurable, defaults on):
- high conviction: recommended_action LONG_HIGH / SHORT_HIGH only
- clean read: risk_level Low (no contradicting signals)
- probable primary target: best target probability >= min (default 60)

- Shared predicate: app/services/qualification.py +
  frontend/src/lib/qualification.ts (mirrored)
- Activation config extended (min_target_probability,
  require_high_conviction, exclude_conflicts) with bool-aware
  get/update + validation
- /trades/performance switched to ?qualified_only=true, applying
  the full gate server-side; confidence breakdown stays unfiltered
- Dashboard "Qualified", Signals "Qualified only" toggle, and
  Track Record all use the one gate; Admin gains the new controls

Sentiment provider runtime config (prior change) included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 11:50:42 +02:00
parent 6da65b8d8f
commit d53ed972d1
25 changed files with 924 additions and 110 deletions
+38
View File
@@ -14,6 +14,8 @@ from app.schemas.admin import (
DataCleanupRequest,
JobToggle,
RecommendationConfigUpdate,
SentimentConfigUpdate,
SentimentTestRequest,
PasswordReset,
RegistrationToggle,
SystemSettingUpdate,
@@ -22,6 +24,7 @@ from app.schemas.admin import (
)
from app.schemas.common import APIEnvelope
from app.services import admin_service
from app.services import sentiment_provider_service
from app.services import ticker_universe_service
router = APIRouter(tags=["admin"])
@@ -171,6 +174,41 @@ async def update_activation_settings(
return APIEnvelope(status="success", data=updated)
@router.get("/admin/settings/sentiment", response_model=APIEnvelope)
async def get_sentiment_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await sentiment_provider_service.get_sentiment_config(db)
return APIEnvelope(status="success", data=config)
@router.put("/admin/settings/sentiment", response_model=APIEnvelope)
async def update_sentiment_settings(
body: SentimentConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await sentiment_provider_service.update_sentiment_config(
db,
provider=body.provider,
model=body.model,
api_key=body.api_key,
)
return APIEnvelope(status="success", data=config)
@router.post("/admin/settings/sentiment/test", response_model=APIEnvelope)
async def test_sentiment_settings(
body: SentimentTestRequest,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Live credentials check: fetch sentiment for one ticker with current config."""
result = await sentiment_provider_service.test_sentiment_provider(db, body.ticker)
return APIEnvelope(status="success", data=result)
@router.get("/admin/settings/ticker-universe", response_model=APIEnvelope)
async def get_ticker_universe_setting(
_admin: User = Depends(require_admin),
+8 -10
View File
@@ -23,8 +23,8 @@ from app.models.ticker import Ticker
from app.models.user import User
from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.providers.openai_sentiment import OpenAISentimentProvider
from app.services.rr_scanner_service import scan_ticker
from app.services.sentiment_provider_service import build_sentiment_provider
from app.schemas.common import APIEnvelope
from app.services import (
fundamental_service,
@@ -99,11 +99,14 @@ async def fetch_symbol(
sources["ohlcv"] = {"status": "error", "records": 0, "message": str(exc)}
# --- Sentiment ---
if settings.openai_api_key:
try:
sent_provider = await build_sentiment_provider(db)
except ProviderError as exc:
sent_provider = None
sources["sentiment"] = {"status": "skipped", "message": str(exc)}
if sent_provider is not None:
try:
sent_provider = OpenAISentimentProvider(
settings.openai_api_key, settings.openai_model
)
data = await sent_provider.fetch_sentiment(symbol_upper)
await sentiment_service.store_sentiment(
db,
@@ -124,11 +127,6 @@ async def fetch_symbol(
except Exception as exc:
logger.error("Sentiment fetch failed for %s: %s", symbol_upper, exc)
sources["sentiment"] = {"status": "error", "message": str(exc)}
else:
sources["sentiment"] = {
"status": "skipped",
"message": "OpenAI API key not configured",
}
# --- Fundamentals ---
if settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key:
+7 -7
View File
@@ -67,9 +67,8 @@ async def get_activation_thresholds(
@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"
qualified_only: bool = Query(
False, description="Restrict overall/direction/action stats to setups that clear the activation gate"
),
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
@@ -78,11 +77,12 @@ 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.
With qualified_only, the overall/direction/action breakdowns cover only
setups clearing the activation gate; the confidence breakdown always
covers all setups so the gate can be validated against it.
"""
stats = await get_performance_stats(db, min_rr=min_rr, min_confidence=min_confidence)
config = await admin_service.get_activation_config(db) if qualified_only else None
stats = await get_performance_stats(db, config=config)
return APIEnvelope(status="success", data=stats)
+8 -7
View File
@@ -29,13 +29,14 @@ from app.models.ohlcv import OHLCVRecord
from app.models.settings import SystemSetting
from app.models.sentiment import SentimentScore
from app.models.ticker import Ticker
from app.exceptions import ProviderError
from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.providers.openai_sentiment import OpenAISentimentProvider
from app.providers.protocol import SentimentData
from app.services import fundamental_service, ingestion_service, sentiment_service
from app.services.outcome_service import evaluate_pending_setups
from app.services.rr_scanner_service import scan_all_tickers
from app.services.sentiment_provider_service import build_sentiment_provider
from app.services.ticker_universe_service import bootstrap_universe
logger = logging.getLogger(__name__)
@@ -407,13 +408,13 @@ async def collect_sentiment() -> None:
total = len(symbols)
_runtime_progress(job_name, processed=0, total=total)
if not settings.openai_api_key:
logger.warning(json.dumps({"event": "job_skipped", "job": job_name, "reason": "openai key not configured"}))
_runtime_finish(job_name, "skipped", processed=0, total=total, message="OpenAI key not configured")
return
try:
provider = OpenAISentimentProvider(settings.openai_api_key, settings.openai_model)
async with async_session_factory() as cfg_db:
provider = await build_sentiment_provider(cfg_db)
except ProviderError as exc:
logger.warning(json.dumps({"event": "job_skipped", "job": job_name, "reason": str(exc)}))
_runtime_finish(job_name, "skipped", processed=0, total=total, message=str(exc))
return
except Exception as exc:
logger.error(json.dumps({"event": "job_error", "job": job_name, "error_type": type(exc).__name__, "message": str(exc)}))
_runtime_finish(job_name, "error", processed=0, total=total, message=str(exc))
+16 -1
View File
@@ -59,6 +59,21 @@ class TickerUniverseUpdate(BaseModel):
class ActivationConfigUpdate(BaseModel):
"""Activation thresholds: what counts as an actionable signal."""
"""Activation gate: 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)
min_target_probability: float | None = Field(default=None, ge=0, le=100)
require_high_conviction: bool | None = None
exclude_conflicts: bool | None = None
class SentimentConfigUpdate(BaseModel):
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
the stored key."""
provider: Literal["openai", "gemini"] | None = None
model: str | None = Field(default=None, max_length=100)
api_key: str | None = Field(default=None, max_length=400)
class SentimentTestRequest(BaseModel):
ticker: str = Field(default="AAPL", max_length=10)
+45 -27
View File
@@ -31,14 +31,29 @@ 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,
# Activation gate: what counts as a signal worth acting on. Used by the
# Dashboard's "Qualified" metric, the Signals "Qualified only" view, and the
# Track Record's qualified stats. The outcome evaluator deliberately ignores
# these — every setup is evaluated so the gate itself can be validated.
#
# Beyond raw R:R and confidence, the gate demands conviction: a high-conviction
# action (LONG_HIGH / SHORT_HIGH), a clean read (risk Low / no conflicts), and a
# probable primary target.
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_rr": "activation_min_rr",
"min_confidence": "activation_min_confidence",
"min_target_probability": "activation_min_target_probability",
}
_ACTIVATION_BOOL_KEYS: dict[str, str] = {
"require_high_conviction": "activation_require_high_conviction",
"exclude_conflicts": "activation_exclude_conflicts",
}
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 60.0,
"require_high_conviction": True,
"exclude_conflicts": True,
}
@@ -157,40 +172,43 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
# Activation thresholds
# ---------------------------------------------------------------------------
async def get_activation_config(db: AsyncSession) -> dict[str, float]:
"""Return activation thresholds with public keys (min_rr, min_confidence)."""
async def get_activation_config(db: AsyncSession) -> dict[str, float | bool]:
"""Return the activation gate config with public keys."""
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:
stored = {s.key: s.value for s in result.scalars().all()}
config: dict[str, float | bool] = dict(ACTIVATION_DEFAULTS)
for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items():
if storage_key in stored:
try:
config[setting.key] = float(setting.value)
config[public_key] = float(stored[storage_key])
except (TypeError, ValueError):
pass
return {
"min_rr": config["activation_min_rr"],
"min_confidence": config["activation_min_confidence"],
}
for public_key, storage_key in _ACTIVATION_BOOL_KEYS.items():
if storage_key in stored:
config[public_key] = str(stored[storage_key]).strip().lower() == "true"
return config
async def update_activation_config(
db: AsyncSession, updates: dict[str, float]
) -> dict[str, float]:
"""Update activation thresholds. Accepts public keys min_rr / min_confidence."""
db: AsyncSession, updates: dict[str, float | bool]
) -> dict[str, float | bool]:
"""Update the activation gate. Accepts public keys; only supplied keys change."""
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")
if "min_target_probability" in updates and not 0 <= updates["min_target_probability"] <= 100:
raise ValidationError("min_target_probability 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:
for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items():
if public_key in updates and updates[public_key] is not None:
await update_setting(db, storage_key, str(float(updates[public_key])))
for public_key, storage_key in _ACTIVATION_BOOL_KEYS.items():
if public_key in updates and updates[public_key] is not None:
await update_setting(db, storage_key, "true" if updates[public_key] else "false")
return await get_activation_config(db)
+10 -13
View File
@@ -23,6 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.trade_setup import TradeSetup
from app.services.qualification import setup_qualifies
logger = logging.getLogger(__name__)
@@ -180,8 +181,7 @@ def _confidence_bucket(score: float | None) -> str | None:
async def get_performance_stats(
db: AsyncSession,
min_rr: float | None = None,
min_confidence: float | None = None,
config: dict | None = None,
) -> dict:
"""Aggregate outcome statistics over all evaluated trade setups.
@@ -189,9 +189,10 @@ async def get_performance_stats(
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.
When ``config`` (an activation-gate dict) is supplied, the overall,
direction and action breakdowns cover only qualified setups. The
confidence breakdown deliberately stays unfiltered: it is the
instrument for validating the gate itself.
"""
result = await db.execute(
select(TradeSetup).where(TradeSetup.actual_outcome.is_not(None))
@@ -203,14 +204,10 @@ async def get_performance_stats(
)
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)]
if config is not None:
qualified = [s for s in evaluated if setup_qualifies(s, config)]
else:
qualified = evaluated
by_direction: dict[str, list[TradeSetup]] = {}
by_action: dict[str, list[TradeSetup]] = {}
+42
View File
@@ -0,0 +1,42 @@
"""Shared definition of a 'qualified' (actionable) trade setup.
A single predicate, driven by the admin activation config, used by the
performance stats (server) and mirrored on the frontend. Beyond raw R:R and
confidence, an actionable setup must show genuine conviction: a high-conviction
recommended action, a clean (conflict-free) read, and a probable primary target.
"""
from __future__ import annotations
from typing import Any
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
def best_target_probability(setup: Any) -> float:
"""Highest probability among a setup's targets, 0 if none."""
targets = getattr(setup, "targets", None) or []
probs = [float(t.get("probability", 0.0)) for t in targets if isinstance(t, dict)]
return max(probs, default=0.0)
def setup_qualifies(setup: Any, config: dict) -> bool:
"""Whether a setup clears the activation gate.
``setup`` is duck-typed: any object exposing rr_ratio, confidence_score,
recommended_action, risk_level and a ``targets`` list of dicts.
"""
if setup.rr_ratio < config["min_rr"]:
return False
if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False
if config.get("require_high_conviction"):
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
return False
if config.get("exclude_conflicts"):
if (setup.risk_level or "") != "Low":
return False
min_tp = float(config.get("min_target_probability", 0.0))
if min_tp > 0 and best_target_probability(setup) < min_tp:
return False
return True
+164
View File
@@ -0,0 +1,164 @@
"""Runtime-configurable sentiment provider.
Lets an admin switch the sentiment LLM (provider, model, API key) at runtime
via SystemSetting, without a redeploy. Precedence for each value:
DB setting > environment variable > hardcoded default.
The API key is write-only: it is persisted but never returned through any
read path. Reads report only whether a key is configured and its source.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.exceptions import ProviderError, ValidationError
from app.models.settings import SystemSetting
from app.services.admin_service import update_setting
logger = logging.getLogger(__name__)
VALID_PROVIDERS = {"openai", "gemini"}
PROVIDER_DEFAULT_MODELS: dict[str, str] = {
"openai": "gpt-4o-mini",
"gemini": "gemini-2.0-flash",
}
# SystemSetting keys
KEY_PROVIDER = "sentiment_provider"
KEY_MODEL = "sentiment_model"
KEY_API_KEY = "sentiment_api_key"
async def _get_settings_map(db: AsyncSession) -> dict[str, str]:
result = await db.execute(
select(SystemSetting).where(
SystemSetting.key.in_([KEY_PROVIDER, KEY_MODEL, KEY_API_KEY])
)
)
return {s.key: s.value for s in result.scalars().all()}
def _env_key_for(provider: str) -> str:
if provider == "openai":
return settings.openai_api_key or ""
if provider == "gemini":
return settings.gemini_api_key or ""
return ""
def _env_model_for(provider: str) -> str:
if provider == "openai":
return settings.openai_model or PROVIDER_DEFAULT_MODELS["openai"]
if provider == "gemini":
return settings.gemini_model or PROVIDER_DEFAULT_MODELS["gemini"]
return PROVIDER_DEFAULT_MODELS.get(provider, "")
async def _resolve(db: AsyncSession) -> tuple[str, str, str, str]:
"""Resolve (provider, model, api_key, api_key_source) from DB > env > default."""
stored = await _get_settings_map(db)
provider = (stored.get(KEY_PROVIDER) or "").strip().lower()
if provider not in VALID_PROVIDERS:
# Default to whichever env key is present, else openai
provider = "openai" if settings.openai_api_key or not settings.gemini_api_key else "gemini"
model = (stored.get(KEY_MODEL) or "").strip() or _env_model_for(provider)
db_key = (stored.get(KEY_API_KEY) or "").strip()
if db_key:
return provider, model, db_key, "database"
env_key = _env_key_for(provider)
if env_key:
return provider, model, env_key, "environment"
return provider, model, "", "none"
async def get_sentiment_config(db: AsyncSession) -> dict:
"""Public config — never includes the raw API key."""
provider, model, api_key, source = await _resolve(db)
return {
"provider": provider,
"model": model,
"api_key_configured": bool(api_key),
"api_key_source": source,
"valid_providers": sorted(VALID_PROVIDERS),
"default_models": PROVIDER_DEFAULT_MODELS,
}
async def update_sentiment_config(
db: AsyncSession,
provider: str | None = None,
model: str | None = None,
api_key: str | None = None,
) -> dict:
"""Persist provider/model/key. An empty/omitted api_key leaves the stored
key untouched (so saving other fields does not wipe credentials)."""
if provider is not None:
provider = provider.strip().lower()
if provider not in VALID_PROVIDERS:
raise ValidationError(
f"Unknown sentiment provider '{provider}'. Valid: {', '.join(sorted(VALID_PROVIDERS))}"
)
await update_setting(db, KEY_PROVIDER, provider)
if model is not None:
model = model.strip()
if model:
await update_setting(db, KEY_MODEL, model)
if api_key: # only overwrite when a non-empty key is supplied
await update_setting(db, KEY_API_KEY, api_key.strip())
return await get_sentiment_config(db)
async def build_sentiment_provider(db: AsyncSession):
"""Construct the active sentiment provider from current config.
Raises ProviderError if no API key is available for the chosen provider.
"""
provider, model, api_key, _source = await _resolve(db)
if not api_key:
raise ProviderError(f"No API key configured for sentiment provider '{provider}'")
if provider == "openai":
from app.providers.openai_sentiment import OpenAISentimentProvider
return OpenAISentimentProvider(api_key, model)
if provider == "gemini":
from app.providers.gemini_sentiment import GeminiSentimentProvider
return GeminiSentimentProvider(api_key, model)
raise ProviderError(f"Unsupported sentiment provider '{provider}'")
async def test_sentiment_provider(db: AsyncSession, ticker: str = "AAPL") -> dict:
"""Build the active provider and fetch one ticker as a live credentials check."""
provider, model, _key, _source = await _resolve(db)
try:
prov = await build_sentiment_provider(db)
data = await prov.fetch_sentiment(ticker.strip().upper())
return {
"ok": True,
"provider": provider,
"model": model,
"ticker": ticker.strip().upper(),
"classification": data.classification,
"confidence": data.confidence,
"reasoning": data.reasoning or None,
}
except Exception as exc:
logger.warning("Sentiment provider test failed: %s", exc)
return {
"ok": False,
"provider": provider,
"model": model,
"error": str(exc),
}
+24
View File
@@ -4,6 +4,8 @@ import type {
AdminUser,
PipelineReadiness,
RecommendationConfig,
SentimentProviderConfig,
SentimentTestResult,
SystemSetting,
TickerUniverse,
TickerUniverseBootstrapResult,
@@ -81,6 +83,28 @@ export function updateActivationSettings(payload: Partial<ActivationConfig>) {
.then((r) => r.data);
}
export function getSentimentSettings() {
return apiClient
.get<SentimentProviderConfig>('admin/settings/sentiment')
.then((r) => r.data);
}
export function updateSentimentSettings(payload: {
provider?: string;
model?: string;
api_key?: string;
}) {
return apiClient
.put<SentimentProviderConfig>('admin/settings/sentiment', payload)
.then((r) => r.data);
}
export function testSentimentSettings(ticker: string) {
return apiClient
.post<SentimentTestResult>('admin/settings/sentiment/test', { ticker })
.then((r) => r.data);
}
export function getTickerUniverseSetting() {
return apiClient
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
+1 -2
View File
@@ -2,8 +2,7 @@ import apiClient from './client';
import type { PerformanceStats } from '../lib/types';
export interface PerformanceParams {
min_rr?: number;
min_confidence?: number;
qualified_only?: boolean;
}
export function getPerformance(params?: PerformanceParams) {
@@ -6,6 +6,9 @@ import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: ActivationConfig = {
min_rr: 2,
min_confidence: 70,
min_target_probability: 60,
require_high_conviction: true,
exclude_conflicts: true,
};
export function ActivationSettings() {
@@ -19,12 +22,12 @@ export function ActivationSettings() {
}, [data]);
const onSave = () => {
update.mutate(form as unknown as Record<string, number>);
update.mutate(form);
};
const onReset = () => {
setForm(DEFAULTS);
update.mutate(DEFAULTS as unknown as Record<string, number>);
update.mutate(DEFAULTS);
};
if (isLoading) return <SkeletonTable rows={2} cols={2} />;
@@ -33,16 +36,16 @@ export function ActivationSettings() {
return (
<div className="glass p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-200">Activation Thresholds</h3>
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</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.
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
Signals "Qualified only" view, and the Track Record's qualified stats. All setups are
still evaluated regardless tighten the gate, then watch qualified expectancy in the
Track Record to find what actually wins.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
<input
@@ -53,6 +56,7 @@ export function ActivationSettings() {
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">Set above your scanner floor or it does nothing.</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Confidence (%)</span>
@@ -66,6 +70,50 @@ export function ActivationSettings() {
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 Target Probability (%)</span>
<input
type="number"
min={0}
max={100}
step={1}
value={form.min_target_probability}
onChange={(e) => setForm((prev) => ({ ...prev, min_target_probability: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">Best target's probability must clear this. 0 disables.</span>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={form.require_high_conviction}
onChange={(e) => setForm((prev) => ({ ...prev, require_high_conviction: e.target.checked }))}
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Require high conviction
<span className="mt-0.5 block text-[11px] text-gray-500">
Only LONG (High) / SHORT (High) — the signals must clearly pick a side.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={form.exclude_conflicts}
onChange={(e) => setForm((prev) => ({ ...prev, exclude_conflicts: e.target.checked }))}
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Exclude conflicted setups
<span className="mt-0.5 block text-[11px] text-gray-500">
Risk level must be Low — drops setups with contradicting signals.
</span>
</span>
</label>
</div>
<div className="flex items-center gap-2">
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import {
useSentimentSettings,
useUpdateSentimentSettings,
useTestSentimentProvider,
} from '../../hooks/useAdmin';
import { Select } from '../ui/Field';
import { SkeletonTable } from '../ui/Skeleton';
import type { SentimentTestResult } from '../../lib/types';
const SOURCE_LABEL: Record<string, string> = {
database: 'configured here',
environment: 'from environment (.env)',
none: 'not configured',
};
export function SentimentProviderSettings() {
const { data, isLoading, isError, error } = useSentimentSettings();
const update = useUpdateSentimentSettings();
const test = useTestSentimentProvider();
const [provider, setProvider] = useState('openai');
const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState('');
const [testResult, setTestResult] = useState<SentimentTestResult | null>(null);
useEffect(() => {
if (data) {
setProvider(data.provider);
setModel(data.model);
}
}, [data]);
if (isLoading) return <SkeletonTable rows={3} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load sentiment settings'}</p>;
if (!data) return null;
const onProviderChange = (next: string) => {
setProvider(next);
// Auto-fill the model with the new provider's default unless the user has a
// custom value that isn't the previous provider's default.
const defaults = data.default_models;
if (!model || Object.values(defaults).includes(model)) {
setModel(defaults[next] ?? '');
}
};
const onSave = () => {
setTestResult(null);
update.mutate({
provider,
model,
...(apiKey ? { api_key: apiKey } : {}),
});
setApiKey('');
};
const onTest = () => {
test.mutate('AAPL', { onSuccess: (res) => setTestResult(res) });
};
const keyConfigured = data.api_key_configured;
return (
<div className="glass p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-200">Sentiment LLM Provider</h3>
<p className="mt-1 text-xs text-gray-500">
Switch the model that powers sentiment analysis without redeploying. Applies to the
scheduled sentiment job and manual fetches on the next run.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Provider</span>
<Select value={provider} onChange={(e) => onProviderChange(e.target.value)} className="w-full !py-2">
{data.valid_providers.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</Select>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Model</span>
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={data.default_models[provider] ?? ''}
className="w-full input-glass px-3 py-2 text-sm"
/>
</label>
</div>
<label className="block space-y-1">
<span className="text-xs text-gray-400">API Key</span>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="new-password"
placeholder={keyConfigured ? '•••••••••• (leave blank to keep current)' : 'Paste API key…'}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-500">
Key status:{' '}
<span className={keyConfigured ? 'text-emerald-400' : 'text-amber-400'}>
{SOURCE_LABEL[data.api_key_source] ?? data.api_key_source}
</span>
{' '}· write-only, never displayed
</span>
</label>
<div className="flex flex-wrap items-center gap-2">
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
{update.isPending ? 'Saving…' : 'Save Provider'}
</button>
<button
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white disabled:opacity-50"
onClick={onTest}
disabled={test.isPending}
>
{test.isPending ? 'Testing (AAPL)…' : 'Test Connection'}
</button>
<span className="text-[11px] text-gray-500">Test fetches live sentiment for AAPL with the saved config.</span>
</div>
{testResult && (
<div
className={`rounded-lg border px-4 py-3 text-sm ${
testResult.ok
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300'
: 'border-red-500/20 bg-red-500/10 text-red-400'
}`}
>
{testResult.ok ? (
<>
<span className="font-medium"> {testResult.provider} / {testResult.model}</span> {testResult.ticker}:{' '}
<span className="font-semibold">{testResult.classification}</span>{' '}
<span className="num">({testResult.confidence}%)</span>
{testResult.reasoning && <p className="mt-1 text-xs text-emerald-300/80">{testResult.reasoning}</p>}
</>
) : (
<>
<span className="font-medium"> Test failed</span>
<p className="mt-1 text-xs">{testResult.error}</p>
</>
)}
</div>
)}
</div>
);
}
+33 -11
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation';
import { useTrades } from '../../hooks/useTrades';
import { qualifiesSetup, activationSummary } from '../../lib/qualification';
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
import { SkeletonTable } from '../ui/Skeleton';
import { useToast } from '../ui/Toast';
@@ -99,18 +100,16 @@ export function SetupsPanel() {
const queryClient = useQueryClient();
const toast = useToast();
// 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);
// "Qualified only" applies the admin activation gate; the manual filters
// below refine within whatever is shown.
const [qualifiedOnly, setQualifiedOnly] = useState(true);
const [minRR, setMinRR] = useState(0);
const [minConfidence, setMinConfidence] = useState(0);
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
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({
mutationFn: () => triggerJob('rr_scanner'),
onSuccess: () => {
@@ -133,12 +132,35 @@ export function SetupsPanel() {
const processed = useMemo(() => {
if (!trades) return [];
const filtered = filterTrades(trades, minRR, directionFilter, minConfidence, actionFilter);
let base = trades;
if (qualifiedOnly && activation.data) {
base = base.filter((t) => qualifiesSetup(t, activation.data!));
}
const filtered = filterTrades(base, minRR, directionFilter, minConfidence, actionFilter);
return sortTrades(filtered, sortColumn, sortDirection);
}, [trades, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
}, [trades, qualifiedOnly, activation.data, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
return (
<div className="space-y-6">
{/* Qualified gate toggle */}
<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 only
{activation.data && (
<span className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
)}
</span>
</label>
<span className="text-xs text-gray-500">Manual filters below refine within this.</span>
</div>
{/* Filter toolbar */}
<div className="glass-sm flex flex-wrap items-end gap-4 p-4">
<Field label="Min Risk:Reward" htmlFor="min-rr">
@@ -150,7 +172,7 @@ export function SetupsPanel() {
min={0}
step={0.1}
value={minRR}
onChange={(e) => setMinRROverride(Number(e.target.value) || 0)}
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
className="w-20"
/>
</div>
@@ -174,7 +196,7 @@ export function SetupsPanel() {
max={100}
step={1}
value={minConfidence}
onChange={(e) => setMinConfidenceOverride(Number(e.target.value) || 0)}
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
className="w-24"
/>
</Field>
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation';
import { activationSummary } from '../../lib/qualification';
import { usePerformance } from '../../hooks/usePerformance';
import { triggerJob } from '../../api/admin';
import { Button } from '../ui/Button';
@@ -94,11 +95,9 @@ export function TrackRecordPanel() {
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 { data, isLoading, isError, error } = usePerformance(
qualifiedOnly ? { qualified_only: true } : undefined,
);
const queryClient = useQueryClient();
const toast = useToast();
@@ -126,9 +125,7 @@ export function TrackRecordPanel() {
<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 className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
)}
</span>
</label>
+37 -2
View File
@@ -126,13 +126,13 @@ export function useUpdateActivationSettings() {
const { addToast } = useToast();
return useMutation({
mutationFn: (payload: Record<string, number>) =>
mutationFn: (payload: Partial<import('../lib/types').ActivationConfig>) =>
adminApi.updateActivationSettings(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'activation-settings'] });
qc.invalidateQueries({ queryKey: ['activation'] });
qc.invalidateQueries({ queryKey: ['performance'] });
addToast('success', 'Activation thresholds updated');
addToast('success', 'Activation gate updated');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update activation thresholds');
@@ -140,6 +140,41 @@ export function useUpdateActivationSettings() {
});
}
export function useSentimentSettings() {
return useQuery({
queryKey: ['admin', 'sentiment-settings'],
queryFn: () => adminApi.getSentimentSettings(),
});
}
export function useUpdateSentimentSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (payload: { provider?: string; model?: string; api_key?: string }) =>
adminApi.updateSentimentSettings(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'sentiment-settings'] });
addToast('success', 'Sentiment provider updated');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update sentiment provider');
},
});
}
export function useTestSentimentProvider() {
const { addToast } = useToast();
return useMutation({
mutationFn: (ticker: string) => adminApi.testSentimentSettings(ticker),
onError: (error: Error) => {
addToast('error', error.message || 'Sentiment test failed');
},
});
}
export function useTickerUniverseSetting() {
return useQuery({
queryKey: ['admin', 'ticker-universe'],
+33
View File
@@ -0,0 +1,33 @@
import type { ActivationConfig, TradeSetup } from './types';
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
export function bestTargetProbability(setup: TradeSetup): number {
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
}
/**
* Whether a setup clears the activation gate. Mirrors the backend predicate in
* app/services/qualification.py — keep the two in sync.
*/
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
if (setup.rr_ratio < config.min_rr) return false;
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false;
}
if (config.exclude_conflicts && (setup.risk_level ?? '') !== 'Low') return false;
if (config.min_target_probability > 0 && bestTargetProbability(setup) < config.min_target_probability) {
return false;
}
return true;
}
/** Short human summary of the active gate, e.g. for tooltips/labels. */
export function activationSummary(config: ActivationConfig): string {
const parts = [`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`];
if (config.require_high_conviction) parts.push('high-conviction');
if (config.exclude_conflicts) parts.push('clean');
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
return parts.join(' · ');
}
+25 -1
View File
@@ -152,10 +152,34 @@ export interface PerformanceStats {
by_confidence: Record<string, OutcomeBucketStats>;
}
// Activation thresholds: what counts as an actionable signal
// Activation gate: what counts as an actionable signal
export interface ActivationConfig {
min_rr: number;
min_confidence: number;
min_target_probability: number;
require_high_conviction: boolean;
exclude_conflicts: boolean;
}
// Runtime sentiment LLM configuration
export interface SentimentProviderConfig {
provider: string;
model: string;
api_key_configured: boolean;
api_key_source: 'database' | 'environment' | 'none';
valid_providers: string[];
default_models: Record<string, string>;
}
export interface SentimentTestResult {
ok: boolean;
provider: string;
model: string;
ticker?: string;
classification?: string;
confidence?: number;
reasoning?: string | null;
error?: string;
}
export interface TradeTarget {
+2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { ActivationSettings } from '../components/admin/ActivationSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
@@ -30,6 +31,7 @@ export default function AdminPage() {
{activeTab === 'Settings' && (
<div className="space-y-4">
<ActivationSettings />
<SentimentProviderSettings />
<TickerUniverseBootstrap />
<RecommendationSettings />
<SettingsForm />
+6 -8
View File
@@ -9,6 +9,7 @@ import { Section } from '../components/ui/Section';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { formatPrice } from '../lib/format';
import { recommendationActionLabel } from '../lib/recommendation';
import { qualifiesSetup, activationSummary } from '../lib/qualification';
import type { TradeSetup } from '../lib/types';
function fmtR(value: number | null): string {
@@ -55,15 +56,12 @@ export default function DashboardPage() {
const activation = useActivation();
const performance = usePerformance();
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],
activation.data
? (trades.data ?? []).filter((t) => qualifiesSetup(t, activation.data!))
: [],
[trades.data, activation.data],
);
// Show qualified setups first; fall back to the full list when none qualify
@@ -112,7 +110,7 @@ export default function DashboardPage() {
<Metric
label="Qualified"
value={String(qualifiedSetups.length)}
sub={`R:R ≥ ${minRR.toFixed(1)} & conf ≥ ${minConfidence.toFixed(0)}%`}
sub={activation.data ? activationSummary(activation.data) : 'clears the activation gate'}
valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'}
/>
<Metric
+1 -1
View File
@@ -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/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"}
{"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/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/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/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"}
+25 -3
View File
@@ -24,16 +24,24 @@ async def session() -> AsyncSession:
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}
assert config == {
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 60.0,
"require_high_conviction": True,
"exclude_conflicts": True,
}
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}
assert updated["min_rr"] == 1.5
assert updated["min_confidence"] == 60.0
config = await get_activation_config(session)
assert config == {"min_rr": 1.5, "min_confidence": 60.0}
assert config["min_rr"] == 1.5
assert config["min_confidence"] == 60.0
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
await update_activation_config(session, {"min_confidence": 80.0})
@@ -41,6 +49,16 @@ class TestActivationConfig:
assert config["min_rr"] == 2.0 # default untouched
assert config["min_confidence"] == 80.0
async def test_conviction_flags_round_trip(self, session: AsyncSession):
await update_activation_config(
session,
{"require_high_conviction": False, "exclude_conflicts": False, "min_target_probability": 45.0},
)
config = await get_activation_config(session)
assert config["require_high_conviction"] is False
assert config["exclude_conflicts"] is False
assert config["min_target_probability"] == 45.0
async def test_rejects_negative_rr(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_rr": -1.0})
@@ -48,3 +66,7 @@ class TestActivationConfig:
async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_confidence": 120.0})
async def test_rejects_out_of_range_target_probability(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_target_probability": 150.0})
+9 -1
View File
@@ -289,7 +289,15 @@ class TestGetPerformanceStats:
))
await db_session.flush()
stats = await get_performance_stats(db_session, min_rr=2.0, min_confidence=70.0)
# Gate on R:R + confidence only (conviction filters off for this test)
config = {
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
}
stats = await get_performance_stats(db_session, config=config)
# Overall covers only the qualified setup
assert stats["overall"]["total"] == 1
+81
View File
@@ -0,0 +1,81 @@
"""Unit tests for the activation qualification predicate."""
from __future__ import annotations
from types import SimpleNamespace
from app.services.qualification import best_target_probability, setup_qualifies
FULL_GATE = {
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 60.0,
"require_high_conviction": True,
"exclude_conflicts": True,
}
def _setup(**kwargs):
base = dict(
rr_ratio=3.0,
confidence_score=80.0,
recommended_action="LONG_HIGH",
risk_level="Low",
targets=[{"probability": 65.0}],
)
base.update(kwargs)
return SimpleNamespace(**base)
class TestSetupQualifies:
def test_clean_high_conviction_setup_passes(self):
assert setup_qualifies(_setup(), FULL_GATE) is True
def test_low_rr_fails(self):
assert setup_qualifies(_setup(rr_ratio=1.5), FULL_GATE) is False
def test_low_confidence_fails(self):
assert setup_qualifies(_setup(confidence_score=60.0), FULL_GATE) is False
def test_moderate_action_fails_when_high_conviction_required(self):
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), FULL_GATE) is False
def test_neutral_action_fails(self):
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), FULL_GATE) is False
def test_short_high_passes(self):
assert setup_qualifies(_setup(recommended_action="SHORT_HIGH"), FULL_GATE) is True
def test_non_low_risk_fails_when_excluding_conflicts(self):
assert setup_qualifies(_setup(risk_level="Medium"), FULL_GATE) is False
assert setup_qualifies(_setup(risk_level="High"), FULL_GATE) is False
def test_low_target_probability_fails(self):
assert setup_qualifies(_setup(targets=[{"probability": 40.0}]), FULL_GATE) is False
def test_no_targets_fails_when_probability_required(self):
assert setup_qualifies(_setup(targets=[]), FULL_GATE) is False
def test_conviction_filters_can_be_disabled(self):
relaxed = {
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
}
# Moderate action, medium risk, no targets — still passes on rr+confidence alone
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium", targets=[])
assert setup_qualifies(s, relaxed) is True
def test_missing_confidence_treated_as_zero(self):
assert setup_qualifies(_setup(confidence_score=None), FULL_GATE) is False
class TestBestTargetProbability:
def test_returns_max(self):
s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}])
assert best_target_probability(s) == 72.0
def test_empty_is_zero(self):
assert best_target_probability(_setup(targets=[])) == 0.0
@@ -0,0 +1,94 @@
"""Unit tests for runtime sentiment provider configuration."""
from __future__ import annotations
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import ProviderError, ValidationError
from app.models.settings import SystemSetting
from app.services import sentiment_provider_service as sps
@pytest.fixture
async def session() -> AsyncSession:
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
@pytest.fixture(autouse=True)
def _clear_env_keys(monkeypatch):
"""Default: no env keys, so DB-only behavior is deterministic."""
monkeypatch.setattr(sps.settings, "openai_api_key", "")
monkeypatch.setattr(sps.settings, "gemini_api_key", "")
class TestGetConfig:
async def test_defaults_no_key(self, session: AsyncSession):
config = await sps.get_sentiment_config(session)
assert config["provider"] == "openai"
assert config["model"] == "gpt-4o-mini"
assert config["api_key_configured"] is False
assert config["api_key_source"] == "none"
assert set(config["valid_providers"]) == {"openai", "gemini"}
async def test_never_returns_raw_key(self, session: AsyncSession):
await sps.update_sentiment_config(session, api_key="sk-secret-123")
config = await sps.get_sentiment_config(session)
# No field should leak the key
assert "sk-secret-123" not in str(config)
assert config["api_key_configured"] is True
assert config["api_key_source"] == "database"
async def test_env_fallback_reported(self, session: AsyncSession, monkeypatch):
monkeypatch.setattr(sps.settings, "openai_api_key", "sk-from-env")
config = await sps.get_sentiment_config(session)
assert config["api_key_configured"] is True
assert config["api_key_source"] == "environment"
class TestUpdateConfig:
async def test_update_provider_and_model(self, session: AsyncSession):
result = await sps.update_sentiment_config(
session, provider="gemini", model="gemini-2.0-flash"
)
assert result["provider"] == "gemini"
assert result["model"] == "gemini-2.0-flash"
async def test_empty_key_does_not_overwrite(self, session: AsyncSession):
await sps.update_sentiment_config(session, api_key="sk-original")
# Subsequent save without a key must keep the original
await sps.update_sentiment_config(session, provider="openai", api_key="")
result = await session.execute(
select(SystemSetting).where(SystemSetting.key == sps.KEY_API_KEY)
)
assert result.scalar_one().value == "sk-original"
async def test_rejects_invalid_provider(self, session: AsyncSession):
with pytest.raises(ValidationError):
await sps.update_sentiment_config(session, provider="anthropic")
class TestBuildProvider:
async def test_raises_without_key(self, session: AsyncSession):
with pytest.raises(ProviderError):
await sps.build_sentiment_provider(session)
async def test_builds_openai(self, session: AsyncSession):
await sps.update_sentiment_config(session, provider="openai", api_key="sk-x")
provider = await sps.build_sentiment_provider(session)
assert type(provider).__name__ == "OpenAISentimentProvider"
async def test_builds_gemini(self, session: AsyncSession):
await sps.update_sentiment_config(session, provider="gemini", api_key="g-x")
provider = await sps.build_sentiment_provider(session)
assert type(provider).__name__ == "GeminiSentimentProvider"
async def test_uses_env_key_when_db_empty(self, session: AsyncSession, monkeypatch):
monkeypatch.setattr(sps.settings, "openai_api_key", "sk-env")
await sps.update_sentiment_config(session, provider="openai")
provider = await sps.build_sentiment_provider(session)
assert type(provider).__name__ == "OpenAISentimentProvider"