Add multi-factor conviction gate to activation
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:
@@ -14,6 +14,8 @@ from app.schemas.admin import (
|
|||||||
DataCleanupRequest,
|
DataCleanupRequest,
|
||||||
JobToggle,
|
JobToggle,
|
||||||
RecommendationConfigUpdate,
|
RecommendationConfigUpdate,
|
||||||
|
SentimentConfigUpdate,
|
||||||
|
SentimentTestRequest,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
RegistrationToggle,
|
RegistrationToggle,
|
||||||
SystemSettingUpdate,
|
SystemSettingUpdate,
|
||||||
@@ -22,6 +24,7 @@ from app.schemas.admin import (
|
|||||||
)
|
)
|
||||||
from app.schemas.common import APIEnvelope
|
from app.schemas.common import APIEnvelope
|
||||||
from app.services import admin_service
|
from app.services import admin_service
|
||||||
|
from app.services import sentiment_provider_service
|
||||||
from app.services import ticker_universe_service
|
from app.services import ticker_universe_service
|
||||||
|
|
||||||
router = APIRouter(tags=["admin"])
|
router = APIRouter(tags=["admin"])
|
||||||
@@ -171,6 +174,41 @@ async def update_activation_settings(
|
|||||||
return APIEnvelope(status="success", data=updated)
|
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)
|
@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),
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ from app.models.ticker import Ticker
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||||
from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
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.rr_scanner_service import scan_ticker
|
||||||
|
from app.services.sentiment_provider_service import build_sentiment_provider
|
||||||
from app.schemas.common import APIEnvelope
|
from app.schemas.common import APIEnvelope
|
||||||
from app.services import (
|
from app.services import (
|
||||||
fundamental_service,
|
fundamental_service,
|
||||||
@@ -99,11 +99,14 @@ async def fetch_symbol(
|
|||||||
sources["ohlcv"] = {"status": "error", "records": 0, "message": str(exc)}
|
sources["ohlcv"] = {"status": "error", "records": 0, "message": str(exc)}
|
||||||
|
|
||||||
# --- Sentiment ---
|
# --- 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:
|
try:
|
||||||
sent_provider = OpenAISentimentProvider(
|
|
||||||
settings.openai_api_key, settings.openai_model
|
|
||||||
)
|
|
||||||
data = await sent_provider.fetch_sentiment(symbol_upper)
|
data = await sent_provider.fetch_sentiment(symbol_upper)
|
||||||
await sentiment_service.store_sentiment(
|
await sentiment_service.store_sentiment(
|
||||||
db,
|
db,
|
||||||
@@ -124,11 +127,6 @@ async def fetch_symbol(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Sentiment fetch failed for %s: %s", symbol_upper, exc)
|
logger.error("Sentiment fetch failed for %s: %s", symbol_upper, exc)
|
||||||
sources["sentiment"] = {"status": "error", "message": str(exc)}
|
sources["sentiment"] = {"status": "error", "message": str(exc)}
|
||||||
else:
|
|
||||||
sources["sentiment"] = {
|
|
||||||
"status": "skipped",
|
|
||||||
"message": "OpenAI API key not configured",
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Fundamentals ---
|
# --- Fundamentals ---
|
||||||
if settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key:
|
if settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key:
|
||||||
|
|||||||
@@ -67,9 +67,8 @@ async def get_activation_thresholds(
|
|||||||
|
|
||||||
@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"),
|
qualified_only: bool = Query(
|
||||||
min_confidence: float | None = Query(
|
False, description="Restrict overall/direction/action stats to setups that clear the activation gate"
|
||||||
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),
|
||||||
@@ -78,11 +77,12 @@ 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
|
With qualified_only, the overall/direction/action breakdowns cover only
|
||||||
and action breakdowns; the confidence breakdown always covers all setups
|
setups clearing the activation gate; the confidence breakdown always
|
||||||
so thresholds can be validated against it.
|
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)
|
return APIEnvelope(status="success", data=stats)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -29,13 +29,14 @@ from app.models.ohlcv import OHLCVRecord
|
|||||||
from app.models.settings import SystemSetting
|
from app.models.settings import SystemSetting
|
||||||
from app.models.sentiment import SentimentScore
|
from app.models.sentiment import SentimentScore
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
|
from app.exceptions import ProviderError
|
||||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||||
from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
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.providers.protocol import SentimentData
|
||||||
from app.services import fundamental_service, ingestion_service, sentiment_service
|
from app.services import fundamental_service, ingestion_service, sentiment_service
|
||||||
from app.services.outcome_service import evaluate_pending_setups
|
from app.services.outcome_service import evaluate_pending_setups
|
||||||
from app.services.rr_scanner_service import scan_all_tickers
|
from app.services.rr_scanner_service import scan_all_tickers
|
||||||
|
from app.services.sentiment_provider_service import build_sentiment_provider
|
||||||
from app.services.ticker_universe_service import bootstrap_universe
|
from app.services.ticker_universe_service import bootstrap_universe
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -407,13 +408,13 @@ async def collect_sentiment() -> None:
|
|||||||
total = len(symbols)
|
total = len(symbols)
|
||||||
_runtime_progress(job_name, processed=0, total=total)
|
_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:
|
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:
|
except Exception as exc:
|
||||||
logger.error(json.dumps({"event": "job_error", "job": job_name, "error_type": type(exc).__name__, "message": str(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))
|
_runtime_finish(job_name, "error", processed=0, total=total, message=str(exc))
|
||||||
|
|||||||
+16
-1
@@ -59,6 +59,21 @@ class TickerUniverseUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ActivationConfigUpdate(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_rr: float | None = Field(default=None, ge=0)
|
||||||
min_confidence: float | None = Field(default=None, ge=0, le=100)
|
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)
|
||||||
|
|||||||
@@ -31,14 +31,29 @@ 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.
|
# Activation gate: what counts as a signal worth acting on. Used by the
|
||||||
# Used as Signals-page default filters, the Dashboard's qualified-setup
|
# Dashboard's "Qualified" metric, the Signals "Qualified only" view, and the
|
||||||
# metrics, and the Track Record's "qualified only" view. The outcome
|
# Track Record's qualified stats. The outcome evaluator deliberately ignores
|
||||||
# evaluator deliberately ignores these — every setup gets evaluated so the
|
# these — every setup is evaluated so the gate itself can be validated.
|
||||||
# thresholds themselves can be validated against outcomes.
|
#
|
||||||
ACTIVATION_DEFAULTS: dict[str, float] = {
|
# Beyond raw R:R and confidence, the gate demands conviction: a high-conviction
|
||||||
"activation_min_rr": 2.0,
|
# action (LONG_HIGH / SHORT_HIGH), a clean read (risk Low / no conflicts), and a
|
||||||
"activation_min_confidence": 70.0,
|
# 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
|
# Activation thresholds
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def get_activation_config(db: AsyncSession) -> dict[str, float]:
|
async def get_activation_config(db: AsyncSession) -> dict[str, float | bool]:
|
||||||
"""Return activation thresholds with public keys (min_rr, min_confidence)."""
|
"""Return the activation gate config with public keys."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemSetting).where(SystemSetting.key.like("activation_%"))
|
select(SystemSetting).where(SystemSetting.key.like("activation_%"))
|
||||||
)
|
)
|
||||||
config = dict(ACTIVATION_DEFAULTS)
|
stored = {s.key: s.value for s in result.scalars().all()}
|
||||||
for setting in result.scalars().all():
|
|
||||||
if setting.key in config:
|
config: dict[str, float | bool] = dict(ACTIVATION_DEFAULTS)
|
||||||
|
for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items():
|
||||||
|
if storage_key in stored:
|
||||||
try:
|
try:
|
||||||
config[setting.key] = float(setting.value)
|
config[public_key] = float(stored[storage_key])
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
return {
|
for public_key, storage_key in _ACTIVATION_BOOL_KEYS.items():
|
||||||
"min_rr": config["activation_min_rr"],
|
if storage_key in stored:
|
||||||
"min_confidence": config["activation_min_confidence"],
|
config[public_key] = str(stored[storage_key]).strip().lower() == "true"
|
||||||
}
|
return config
|
||||||
|
|
||||||
|
|
||||||
async def update_activation_config(
|
async def update_activation_config(
|
||||||
db: AsyncSession, updates: dict[str, float]
|
db: AsyncSession, updates: dict[str, float | bool]
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float | bool]:
|
||||||
"""Update activation thresholds. Accepts public keys min_rr / min_confidence."""
|
"""Update the activation gate. Accepts public keys; only supplied keys change."""
|
||||||
if "min_rr" in updates and updates["min_rr"] < 0:
|
if "min_rr" in updates and updates["min_rr"] < 0:
|
||||||
raise ValidationError("min_rr must be >= 0")
|
raise ValidationError("min_rr must be >= 0")
|
||||||
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100:
|
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100:
|
||||||
raise ValidationError("min_confidence must be between 0 and 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 = {
|
for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items():
|
||||||
"min_rr": "activation_min_rr",
|
if public_key in updates and updates[public_key] is not None:
|
||||||
"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])))
|
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)
|
return await get_activation_config(db)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
|
from app.services.qualification import setup_qualifies
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -180,8 +181,7 @@ def _confidence_bucket(score: float | None) -> str | None:
|
|||||||
|
|
||||||
async def get_performance_stats(
|
async def get_performance_stats(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
min_rr: float | None = None,
|
config: dict | None = None,
|
||||||
min_confidence: float | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Aggregate outcome statistics over all evaluated trade setups.
|
"""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
|
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
|
When ``config`` (an activation-gate dict) is supplied, the overall,
|
||||||
breakdowns. The confidence breakdown deliberately stays unfiltered:
|
direction and action breakdowns cover only qualified setups. The
|
||||||
it is the instrument for validating the thresholds themselves.
|
confidence breakdown deliberately stays unfiltered: it is the
|
||||||
|
instrument for validating the gate itself.
|
||||||
"""
|
"""
|
||||||
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))
|
||||||
@@ -203,14 +204,10 @@ async def get_performance_stats(
|
|||||||
)
|
)
|
||||||
pending_count = len(pending_result.scalars().all())
|
pending_count = len(pending_result.scalars().all())
|
||||||
|
|
||||||
def qualifies(setup: TradeSetup) -> bool:
|
if config is not None:
|
||||||
if min_rr is not None and setup.rr_ratio < min_rr:
|
qualified = [s for s in evaluated if setup_qualifies(s, config)]
|
||||||
return False
|
else:
|
||||||
if min_confidence is not None and (setup.confidence_score or 0.0) < min_confidence:
|
qualified = evaluated
|
||||||
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]] = {}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
AdminUser,
|
AdminUser,
|
||||||
PipelineReadiness,
|
PipelineReadiness,
|
||||||
RecommendationConfig,
|
RecommendationConfig,
|
||||||
|
SentimentProviderConfig,
|
||||||
|
SentimentTestResult,
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
TickerUniverse,
|
TickerUniverse,
|
||||||
TickerUniverseBootstrapResult,
|
TickerUniverseBootstrapResult,
|
||||||
@@ -81,6 +83,28 @@ export function updateActivationSettings(payload: Partial<ActivationConfig>) {
|
|||||||
.then((r) => r.data);
|
.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() {
|
export function getTickerUniverseSetting() {
|
||||||
return apiClient
|
return apiClient
|
||||||
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
|
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import apiClient from './client';
|
|||||||
import type { PerformanceStats } from '../lib/types';
|
import type { PerformanceStats } from '../lib/types';
|
||||||
|
|
||||||
export interface PerformanceParams {
|
export interface PerformanceParams {
|
||||||
min_rr?: number;
|
qualified_only?: boolean;
|
||||||
min_confidence?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPerformance(params?: PerformanceParams) {
|
export function getPerformance(params?: PerformanceParams) {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { SkeletonTable } from '../ui/Skeleton';
|
|||||||
const DEFAULTS: ActivationConfig = {
|
const DEFAULTS: ActivationConfig = {
|
||||||
min_rr: 2,
|
min_rr: 2,
|
||||||
min_confidence: 70,
|
min_confidence: 70,
|
||||||
|
min_target_probability: 60,
|
||||||
|
require_high_conviction: true,
|
||||||
|
exclude_conflicts: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ActivationSettings() {
|
export function ActivationSettings() {
|
||||||
@@ -19,12 +22,12 @@ export function ActivationSettings() {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const onSave = () => {
|
const onSave = () => {
|
||||||
update.mutate(form as unknown as Record<string, number>);
|
update.mutate(form);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onReset = () => {
|
const onReset = () => {
|
||||||
setForm(DEFAULTS);
|
setForm(DEFAULTS);
|
||||||
update.mutate(DEFAULTS as unknown as Record<string, number>);
|
update.mutate(DEFAULTS);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <SkeletonTable rows={2} cols={2} />;
|
if (isLoading) return <SkeletonTable rows={2} cols={2} />;
|
||||||
@@ -33,16 +36,16 @@ export function ActivationSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="glass p-5 space-y-4">
|
<div className="glass p-5 space-y-4">
|
||||||
<div>
|
<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">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
What counts as a signal worth acting on. Used as the default Signals filters, the
|
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
|
||||||
Dashboard's qualified-setup metrics, and the Track Record's "qualified only" view.
|
Signals "Qualified only" view, and the Track Record's qualified stats. All setups are
|
||||||
All setups are still evaluated regardless, so these thresholds can be validated
|
still evaluated regardless — tighten the gate, then watch qualified expectancy in the
|
||||||
against the confidence breakdown.
|
Track Record to find what actually wins.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
||||||
<input
|
<input
|
||||||
@@ -53,6 +56,7 @@ export function ActivationSettings() {
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
|
||||||
className="w-full input-glass px-3 py-2 text-sm"
|
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>
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Min Confidence (%)</span>
|
<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"
|
className="w-full input-glass px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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 { useActivation } from '../../hooks/useActivation';
|
||||||
import { useTrades } from '../../hooks/useTrades';
|
import { useTrades } from '../../hooks/useTrades';
|
||||||
|
import { qualifiesSetup, activationSummary } from '../../lib/qualification';
|
||||||
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';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
@@ -99,18 +100,16 @@ export function SetupsPanel() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// null = user hasn't touched the filter; falls back to admin-configured
|
// "Qualified only" applies the admin activation gate; the manual filters
|
||||||
// activation thresholds once loaded
|
// below refine within whatever is shown.
|
||||||
const [minRROverride, setMinRROverride] = useState<number | null>(null);
|
const [qualifiedOnly, setQualifiedOnly] = useState(true);
|
||||||
const [minConfidenceOverride, setMinConfidenceOverride] = useState<number | null>(null);
|
const [minRR, setMinRR] = useState(0);
|
||||||
|
const [minConfidence, setMinConfidence] = useState(0);
|
||||||
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
|
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
|
||||||
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: () => {
|
||||||
@@ -133,12 +132,35 @@ export function SetupsPanel() {
|
|||||||
|
|
||||||
const processed = useMemo(() => {
|
const processed = useMemo(() => {
|
||||||
if (!trades) return [];
|
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);
|
return sortTrades(filtered, sortColumn, sortDirection);
|
||||||
}, [trades, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
|
}, [trades, qualifiedOnly, activation.data, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Filter toolbar */}
|
||||||
<div className="glass-sm flex flex-wrap items-end gap-4 p-4">
|
<div className="glass-sm flex flex-wrap items-end gap-4 p-4">
|
||||||
<Field label="Min Risk:Reward" htmlFor="min-rr">
|
<Field label="Min Risk:Reward" htmlFor="min-rr">
|
||||||
@@ -150,7 +172,7 @@ export function SetupsPanel() {
|
|||||||
min={0}
|
min={0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={minRR}
|
value={minRR}
|
||||||
onChange={(e) => setMinRROverride(Number(e.target.value) || 0)}
|
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
|
||||||
className="w-20"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +196,7 @@ export function SetupsPanel() {
|
|||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
value={minConfidence}
|
value={minConfidence}
|
||||||
onChange={(e) => setMinConfidenceOverride(Number(e.target.value) || 0)}
|
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
|
||||||
className="w-24"
|
className="w-24"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useActivation } from '../../hooks/useActivation';
|
import { useActivation } from '../../hooks/useActivation';
|
||||||
|
import { activationSummary } from '../../lib/qualification';
|
||||||
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';
|
||||||
@@ -94,11 +95,9 @@ export function TrackRecordPanel() {
|
|||||||
const [qualifiedOnly, setQualifiedOnly] = useState(true);
|
const [qualifiedOnly, setQualifiedOnly] = useState(true);
|
||||||
const activation = useActivation();
|
const activation = useActivation();
|
||||||
|
|
||||||
const params = qualifiedOnly && activation.data
|
const { data, isLoading, isError, error } = usePerformance(
|
||||||
? { min_rr: activation.data.min_rr, min_confidence: activation.data.min_confidence }
|
qualifiedOnly ? { qualified_only: true } : undefined,
|
||||||
: undefined;
|
);
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = usePerformance(params);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -126,9 +125,7 @@ export function TrackRecordPanel() {
|
|||||||
<span>
|
<span>
|
||||||
Qualified signals only
|
Qualified signals only
|
||||||
{activation.data && (
|
{activation.data && (
|
||||||
<span className="num ml-2 text-xs text-gray-500">
|
<span className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
|
||||||
R:R ≥ {activation.data.min_rr.toFixed(1)} · conf ≥ {activation.data.min_confidence.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -126,13 +126,13 @@ export function useUpdateActivationSettings() {
|
|||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: Record<string, number>) =>
|
mutationFn: (payload: Partial<import('../lib/types').ActivationConfig>) =>
|
||||||
adminApi.updateActivationSettings(payload),
|
adminApi.updateActivationSettings(payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'activation-settings'] });
|
qc.invalidateQueries({ queryKey: ['admin', 'activation-settings'] });
|
||||||
qc.invalidateQueries({ queryKey: ['activation'] });
|
qc.invalidateQueries({ queryKey: ['activation'] });
|
||||||
qc.invalidateQueries({ queryKey: ['performance'] });
|
qc.invalidateQueries({ queryKey: ['performance'] });
|
||||||
addToast('success', 'Activation thresholds updated');
|
addToast('success', 'Activation gate updated');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
addToast('error', error.message || 'Failed to update activation thresholds');
|
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() {
|
export function useTickerUniverseSetting() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'ticker-universe'],
|
queryKey: ['admin', 'ticker-universe'],
|
||||||
|
|||||||
@@ -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(' · ');
|
||||||
|
}
|
||||||
@@ -152,10 +152,34 @@ export interface PerformanceStats {
|
|||||||
by_confidence: Record<string, OutcomeBucketStats>;
|
by_confidence: Record<string, OutcomeBucketStats>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activation thresholds: what counts as an actionable signal
|
// Activation gate: what counts as an actionable signal
|
||||||
export interface ActivationConfig {
|
export interface ActivationConfig {
|
||||||
min_rr: number;
|
min_rr: number;
|
||||||
min_confidence: 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 {
|
export interface TradeTarget {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ActivationSettings } from '../components/admin/ActivationSettings';
|
import { ActivationSettings } from '../components/admin/ActivationSettings';
|
||||||
|
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||||
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';
|
||||||
@@ -30,6 +31,7 @@ export default function AdminPage() {
|
|||||||
{activeTab === 'Settings' && (
|
{activeTab === 'Settings' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ActivationSettings />
|
<ActivationSettings />
|
||||||
|
<SentimentProviderSettings />
|
||||||
<TickerUniverseBootstrap />
|
<TickerUniverseBootstrap />
|
||||||
<RecommendationSettings />
|
<RecommendationSettings />
|
||||||
<SettingsForm />
|
<SettingsForm />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Section } from '../components/ui/Section';
|
|||||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||||
import { formatPrice } from '../lib/format';
|
import { formatPrice } from '../lib/format';
|
||||||
import { recommendationActionLabel } from '../lib/recommendation';
|
import { recommendationActionLabel } from '../lib/recommendation';
|
||||||
|
import { qualifiesSetup, activationSummary } from '../lib/qualification';
|
||||||
import type { TradeSetup } from '../lib/types';
|
import type { TradeSetup } from '../lib/types';
|
||||||
|
|
||||||
function fmtR(value: number | null): string {
|
function fmtR(value: number | null): string {
|
||||||
@@ -55,15 +56,12 @@ export default function DashboardPage() {
|
|||||||
const activation = useActivation();
|
const activation = useActivation();
|
||||||
const performance = usePerformance();
|
const performance = usePerformance();
|
||||||
|
|
||||||
const minRR = activation.data?.min_rr ?? 2;
|
|
||||||
const minConfidence = activation.data?.min_confidence ?? 70;
|
|
||||||
|
|
||||||
const qualifiedSetups = useMemo(
|
const qualifiedSetups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(trades.data ?? []).filter(
|
activation.data
|
||||||
(t) => t.rr_ratio >= minRR && (t.confidence_score ?? 0) >= minConfidence,
|
? (trades.data ?? []).filter((t) => qualifiesSetup(t, activation.data!))
|
||||||
),
|
: [],
|
||||||
[trades.data, minRR, minConfidence],
|
[trades.data, activation.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show qualified setups first; fall back to the full list when none qualify
|
// Show qualified setups first; fall back to the full list when none qualify
|
||||||
@@ -112,7 +110,7 @@ export default function DashboardPage() {
|
|||||||
<Metric
|
<Metric
|
||||||
label="Qualified"
|
label="Qualified"
|
||||||
value={String(qualifiedSetups.length)}
|
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'}
|
valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -24,16 +24,24 @@ async def session() -> AsyncSession:
|
|||||||
class TestActivationConfig:
|
class TestActivationConfig:
|
||||||
async def test_defaults_when_unset(self, session: AsyncSession):
|
async def test_defaults_when_unset(self, session: AsyncSession):
|
||||||
config = await get_activation_config(session)
|
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):
|
async def test_update_and_read_back(self, session: AsyncSession):
|
||||||
updated = await update_activation_config(
|
updated = await update_activation_config(
|
||||||
session, {"min_rr": 1.5, "min_confidence": 60.0}
|
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)
|
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):
|
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
|
||||||
await update_activation_config(session, {"min_confidence": 80.0})
|
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_rr"] == 2.0 # default untouched
|
||||||
assert config["min_confidence"] == 80.0
|
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):
|
async def test_rejects_negative_rr(self, session: AsyncSession):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
await update_activation_config(session, {"min_rr": -1.0})
|
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):
|
async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
await update_activation_config(session, {"min_confidence": 120.0})
|
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})
|
||||||
|
|||||||
@@ -289,7 +289,15 @@ class TestGetPerformanceStats:
|
|||||||
))
|
))
|
||||||
await db_session.flush()
|
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
|
# Overall covers only the qualified setup
|
||||||
assert stats["overall"]["total"] == 1
|
assert stats["overall"]["total"] == 1
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user