Fix manual refresh dropping qualified ranks and clarify trade UI.
Single-ticker fetch now attaches residual-momentum ranks so setups do not silently fail the activation gate. Exit plan is a timeline, chart labels move left of the price scale, and missing ranks surface explicitly.
This commit is contained in:
@@ -23,7 +23,10 @@ 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.services.rr_scanner_service import scan_ticker
|
from app.services.rr_scanner_service import (
|
||||||
|
resolve_activation_ranks_for_symbol,
|
||||||
|
scan_ticker,
|
||||||
|
)
|
||||||
from app.services.sentiment_provider_service import build_sentiment_provider
|
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 (
|
||||||
@@ -216,15 +219,23 @@ async def fetch_symbol(
|
|||||||
sources_out["scores"] = {"status": "error", "message": str(exc)}
|
sources_out["scores"] = {"status": "error", "message": str(exc)}
|
||||||
|
|
||||||
# --- Derived pipeline: scanner (free, always) ---
|
# --- Derived pipeline: scanner (free, always) ---
|
||||||
|
# Attach the same residual-momentum / strategy ranks the daily scan writes.
|
||||||
|
# Without them the new setup lands with null momentum_percentile and fails
|
||||||
|
# the activation gate (missing ranks do not qualify).
|
||||||
try:
|
try:
|
||||||
|
ranks = await resolve_activation_ranks_for_symbol(db, symbol_upper)
|
||||||
setups = await scan_ticker(
|
setups = await scan_ticker(
|
||||||
db,
|
db,
|
||||||
symbol_upper,
|
symbol_upper,
|
||||||
rr_threshold=settings.default_rr_threshold,
|
rr_threshold=settings.default_rr_threshold,
|
||||||
|
momentum_percentile=ranks.get("momentum_percentile"),
|
||||||
|
strategy_rank=ranks.get("strategy_rank"),
|
||||||
|
volatility_percentile=ranks.get("volatility_percentile"),
|
||||||
)
|
)
|
||||||
sources_out["scanner"] = {
|
sources_out["scanner"] = {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"setups_found": len(setups),
|
"setups_found": len(setups),
|
||||||
|
"momentum_percentile": ranks.get("momentum_percentile"),
|
||||||
"message": None,
|
"message": None,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -428,6 +428,77 @@ async def _create_signal_context_snapshots(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_activation_ranks_for_symbol(
|
||||||
|
db: AsyncSession,
|
||||||
|
symbol: str,
|
||||||
|
) -> dict[str, float | None]:
|
||||||
|
"""Universe activation ranks for one symbol (manual single-ticker scans).
|
||||||
|
|
||||||
|
The daily ``scan_all_tickers`` path ranks the whole universe once and passes
|
||||||
|
percentiles into ``scan_ticker``. Manual refresh must do the same: without
|
||||||
|
``momentum_percentile`` the activation gate treats the setup as unranked and
|
||||||
|
it silently drops out of qualified trades.
|
||||||
|
|
||||||
|
Prefer a fresh cross-sectional rank; if ranking fails or the symbol is
|
||||||
|
missing from the universe slice, fall back to the most recent prior setup
|
||||||
|
that still carries ranks so a refresh never zeroes the gate inputs.
|
||||||
|
"""
|
||||||
|
symbol_u = symbol.strip().upper()
|
||||||
|
empty: dict[str, float | None] = {
|
||||||
|
"momentum_percentile": None,
|
||||||
|
"strategy_rank": None,
|
||||||
|
"volatility_percentile": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import momentum_service
|
||||||
|
|
||||||
|
ranks = await momentum_service.compute_activation_ranks(db)
|
||||||
|
hit = ranks.get(symbol_u)
|
||||||
|
if hit is not None and hit.get("momentum_percentile") is not None:
|
||||||
|
return {
|
||||||
|
"momentum_percentile": hit.get("momentum_percentile"),
|
||||||
|
"strategy_rank": hit.get("strategy_rank"),
|
||||||
|
"volatility_percentile": hit.get("volatility_percentile"),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Activation ranking failed for single-ticker scan of %s", symbol_u
|
||||||
|
)
|
||||||
|
|
||||||
|
ticker_result = await db.execute(
|
||||||
|
select(Ticker.id).where(Ticker.symbol == symbol_u)
|
||||||
|
)
|
||||||
|
ticker_id = ticker_result.scalar_one_or_none()
|
||||||
|
if ticker_id is None:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
prev_result = await db.execute(
|
||||||
|
select(TradeSetup)
|
||||||
|
.where(
|
||||||
|
TradeSetup.ticker_id == ticker_id,
|
||||||
|
TradeSetup.momentum_percentile.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
prev = prev_result.scalar_one_or_none()
|
||||||
|
if prev is None:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
return {
|
||||||
|
"momentum_percentile": (
|
||||||
|
float(prev.momentum_percentile) if prev.momentum_percentile is not None else None
|
||||||
|
),
|
||||||
|
"strategy_rank": (
|
||||||
|
float(prev.strategy_rank) if prev.strategy_rank is not None else None
|
||||||
|
),
|
||||||
|
"volatility_percentile": (
|
||||||
|
float(prev.volatility_percentile) if prev.volatility_percentile is not None else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def scan_ticker(
|
async def scan_ticker(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
|
|||||||
@@ -148,8 +148,17 @@ export function CandlestickChart({
|
|||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
ctx.clearRect(0, 0, W, H);
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
// Margins
|
// Current price = explicit prop, else latest close
|
||||||
const ml = 12, mr = 70, mt = 12, mb = 32;
|
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
||||||
|
// Only the nearest support/resistance are drawn — keep the chart legible
|
||||||
|
const markers = nearestSRMarkers(srLevels, zones, livePrice);
|
||||||
|
|
||||||
|
// Margins: line labels (Entry / Stop / Support…) on the LEFT, pure price
|
||||||
|
// ticks on the RIGHT. Packing both on the right made trade overlays unreadable.
|
||||||
|
const hasLineLabels = Boolean(tradeSetup) || markers.length > 0;
|
||||||
|
const ml = hasLineLabels ? 78 : 16;
|
||||||
|
const mr = 58;
|
||||||
|
const mt = 12, mb = 32;
|
||||||
const cw = W - ml - mr;
|
const cw = W - ml - mr;
|
||||||
const volumeH = VOLUME_PANE_HEIGHT;
|
const volumeH = VOLUME_PANE_HEIGHT;
|
||||||
const ch = H - mt - mb - volumeH - PANE_GAP;
|
const ch = H - mt - mb - volumeH - PANE_GAP;
|
||||||
@@ -157,11 +166,6 @@ export function CandlestickChart({
|
|||||||
const volumeTop = priceBottom + PANE_GAP;
|
const volumeTop = priceBottom + PANE_GAP;
|
||||||
const volumeBottom = volumeTop + volumeH;
|
const volumeBottom = volumeTop + volumeH;
|
||||||
|
|
||||||
// Current price = explicit prop, else latest close
|
|
||||||
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
|
||||||
// Only the nearest support/resistance are drawn — keep the chart legible
|
|
||||||
const markers = nearestSRMarkers(srLevels, zones, livePrice);
|
|
||||||
|
|
||||||
// Price range from visible data
|
// Price range from visible data
|
||||||
const allPrices = visibleData.flatMap((b) => [b.high, b.low]);
|
const allPrices = visibleData.flatMap((b) => [b.high, b.low]);
|
||||||
const srPrices = markers.flatMap((m) => [m.low, m.high]);
|
const srPrices = markers.flatMap((m) => [m.low, m.high]);
|
||||||
@@ -180,13 +184,13 @@ export function CandlestickChart({
|
|||||||
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
|
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
|
||||||
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
|
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
|
||||||
|
|
||||||
// Grid lines (horizontal)
|
// Grid lines (horizontal) + pure price scale on the RIGHT
|
||||||
const nTicks = 6;
|
const nTicks = 6;
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.fillStyle = '#6e7484';
|
ctx.fillStyle = '#6e7484';
|
||||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'left';
|
||||||
for (let i = 0; i <= nTicks; i++) {
|
for (let i = 0; i <= nTicks; i++) {
|
||||||
const v = lo + ((hi - lo) * i) / nTicks;
|
const v = lo + ((hi - lo) * i) / nTicks;
|
||||||
const y = yScale(v);
|
const y = yScale(v);
|
||||||
@@ -194,7 +198,7 @@ export function CandlestickChart({
|
|||||||
ctx.moveTo(ml, y);
|
ctx.moveTo(ml, y);
|
||||||
ctx.lineTo(ml + cw, y);
|
ctx.lineTo(ml + cw, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.fillText(formatPrice(v), W - 8, y + 4);
|
ctx.fillText(formatPrice(v), ml + cw + 6, y + 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// X-axis labels
|
// X-axis labels
|
||||||
@@ -285,6 +289,10 @@ export function CandlestickChart({
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect left-side line labels; prices live on the right axis only.
|
||||||
|
type LineLabel = { y: number; text: string; color: string; weight?: 'normal' | 'bold' };
|
||||||
|
const lineLabels: LineLabel[] = [];
|
||||||
|
|
||||||
// Nearest support/resistance only (band if it came from a zone)
|
// Nearest support/resistance only (band if it came from a zone)
|
||||||
markers.forEach((m) => {
|
markers.forEach((m) => {
|
||||||
const isSupport = m.role === 'support';
|
const isSupport = m.role === 'support';
|
||||||
@@ -311,14 +319,11 @@ export function CandlestickChart({
|
|||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
ctx.fillStyle = color;
|
lineLabels.push({
|
||||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
y: yMid,
|
||||||
ctx.textAlign = 'left';
|
text: isSupport ? 'Support' : 'Resist',
|
||||||
ctx.fillText(
|
color,
|
||||||
`${isSupport ? 'Support' : 'Resistance'} ${formatPrice(m.price)} (${m.strength})`,
|
});
|
||||||
ml + cw + 4,
|
|
||||||
yMid + 3,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trade setup overlay (drawn before candles so candles render on top)
|
// Trade setup overlay (drawn before candles so candles render on top)
|
||||||
@@ -363,15 +368,11 @@ export function CandlestickChart({
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
// Labels on right side
|
lineLabels.push(
|
||||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
{ y: entryY, text: 'Entry', color: 'rgba(154, 160, 176, 0.95)', weight: 'bold' },
|
||||||
ctx.textAlign = 'left';
|
{ y: stopY, text: 'Stop', color: 'rgba(239, 145, 130, 0.95)', weight: 'bold' },
|
||||||
ctx.fillStyle = 'rgba(154, 160, 176, 0.95)';
|
{ y: targetY, text: 'Gate', color: 'rgba(196, 181, 253, 0.95)', weight: 'bold' },
|
||||||
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
|
);
|
||||||
ctx.fillStyle = 'rgba(239, 145, 130, 0.9)';
|
|
||||||
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
|
|
||||||
ctx.fillStyle = 'rgba(196, 181, 253, 0.95)';
|
|
||||||
ctx.fillText(`Gate ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current price line — the anchor for everything else (drawn on top)
|
// Current price line — the anchor for everything else (drawn on top)
|
||||||
@@ -384,15 +385,58 @@ export function CandlestickChart({
|
|||||||
ctx.lineTo(ml + cw, py);
|
ctx.lineTo(ml + cw, py);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
const label = `Now ${formatPrice(livePrice)}`;
|
lineLabels.push({
|
||||||
|
y: py,
|
||||||
|
text: 'Now',
|
||||||
|
color: 'rgba(226, 232, 240, 0.95)',
|
||||||
|
weight: 'bold',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left-side role labels (no prices — those sit on the right axis). Spread
|
||||||
|
// stacked labels so Entry/Stop/Gate don't paint over each other when close.
|
||||||
|
if (lineLabels.length > 0) {
|
||||||
|
const ordered = [...lineLabels].sort((a, b) => a.y - b.y);
|
||||||
|
const minGap = 13;
|
||||||
|
for (let i = 1; i < ordered.length; i++) {
|
||||||
|
if (ordered[i].y - ordered[i - 1].y < minGap) {
|
||||||
|
ordered[i].y = ordered[i - 1].y + minGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we overflow the pane bottom, pull the stack back up.
|
||||||
|
const maxY = mt + ch - 4;
|
||||||
|
if (ordered.length > 0 && ordered[ordered.length - 1].y > maxY) {
|
||||||
|
let shift = ordered[ordered.length - 1].y - maxY;
|
||||||
|
for (let i = ordered.length - 1; i >= 0 && shift > 0; i--) {
|
||||||
|
const prevFloor = i === 0 ? mt + 4 : ordered[i - 1].y + minGap;
|
||||||
|
const room = ordered[i].y - prevFloor;
|
||||||
|
const pull = Math.min(shift, Math.max(0, room));
|
||||||
|
ordered[i].y -= pull;
|
||||||
|
shift -= pull;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||||
const tw = ctx.measureText(label).width;
|
ctx.textAlign = 'right';
|
||||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.95)';
|
|
||||||
ctx.fillRect(ml + 2, py - 8, tw + 8, 16);
|
|
||||||
ctx.fillStyle = '#0a0b11';
|
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(label, ml + 6, py);
|
for (const label of ordered) {
|
||||||
|
ctx.fillStyle = label.color;
|
||||||
|
if (label.weight === 'bold') {
|
||||||
|
ctx.font = '600 10px "IBM Plex Mono", ui-monospace, monospace';
|
||||||
|
} else {
|
||||||
|
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||||
|
}
|
||||||
|
// Short connector tick from label into the plot edge
|
||||||
|
ctx.strokeStyle = label.color;
|
||||||
|
ctx.globalAlpha = 0.35;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ml - 4, label.y);
|
||||||
|
ctx.lineTo(ml, label.y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillText(label.text, ml - 8, label.y);
|
||||||
|
}
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,56 +7,119 @@ import { formatPrice } from '../../lib/format';
|
|||||||
* Deliberately sits *above* the levels ladder: the levels are context, this is
|
* Deliberately sits *above* the levels ladder: the levels are context, this is
|
||||||
* the plan. Before this existed the card showed a "Target" with the same visual
|
* the plan. Before this existed the card showed a "Target" with the same visual
|
||||||
* weight as the entry, implying a take-profit that the live exit never fires.
|
* weight as the entry, implying a take-profit that the live exit never fires.
|
||||||
|
*
|
||||||
|
* Laid out as a vertical timeline (not label↔value rows) so the sequence —
|
||||||
|
* stop protects → trail arms → trail rides → time stop — reads top to bottom.
|
||||||
*/
|
*/
|
||||||
export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction: string }) {
|
export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction: string }) {
|
||||||
const isLong = direction === 'long';
|
const isLong = direction === 'long';
|
||||||
|
const steps = buildSteps(plan, isLong);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-white/[0.07] bg-white/[0.02] p-3">
|
<div className="mt-5 rounded-xl border border-white/[0.07] bg-white/[0.02] p-4">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">how this exits</span>
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">how this exits</span>
|
||||||
<span className="text-[11.5px] font-medium text-gray-300">{plan.headline}</span>
|
<span className="text-[11.5px] font-medium text-gray-300">{plan.headline}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-2.5 grid gap-x-4 gap-y-1.5 text-[11.5px] sm:grid-cols-2">
|
<ol className="relative mt-4 space-y-0">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
{steps.map((step, i) => {
|
||||||
<dt className="text-gray-500">Initial stop</dt>
|
const isLast = i === steps.length - 1;
|
||||||
<dd className="num text-gray-200">
|
return (
|
||||||
{formatPrice(plan.initialStop)}{' '}
|
<li key={step.title} className="relative flex gap-3 pb-4 last:pb-0">
|
||||||
<span className="text-gray-600">(1R = {formatPrice(plan.riskPerShare)}/sh)</span>
|
{/* Spine */}
|
||||||
</dd>
|
<div className="flex w-5 shrink-0 flex-col items-center">
|
||||||
</div>
|
<span
|
||||||
|
className={`num mt-0.5 flex h-5 w-5 items-center justify-center rounded-full border text-[10px] font-semibold ${
|
||||||
{plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null && (
|
step.tone === 'muted'
|
||||||
<>
|
? 'border-white/[0.12] text-gray-500'
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
: 'border-blue-400/35 bg-blue-400/10 text-blue-200'
|
||||||
<dt className="text-gray-500">Trail takes over</dt>
|
}`}
|
||||||
<dd className="num text-gray-200">
|
aria-hidden
|
||||||
{isLong ? 'above' : 'below'} {formatPrice(plan.trailTakesOverAt)}
|
>
|
||||||
</dd>
|
{step.icon ?? i + 1}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-baseline justify-between gap-2 sm:col-span-2">
|
{!isLast && (
|
||||||
<dt className="text-gray-500">Then it trails</dt>
|
<span className="mt-1 w-px flex-1 min-h-[12px] bg-white/[0.08]" aria-hidden />
|
||||||
<dd className="num text-gray-200">
|
|
||||||
{formatPrice(plan.trailWidth ?? 0)} ({plan.trailWidthR.toFixed(1)}R) below the highest close
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<dt className="text-gray-500">Max hold</dt>
|
|
||||||
<dd className="num text-gray-200">{plan.maxHoldDays} trading days</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
|
||||||
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
|
<p className="text-[12px] font-medium text-gray-200">{step.title}</p>
|
||||||
|
{step.primary && (
|
||||||
|
<p className="num mt-0.5 text-[13px] font-semibold tracking-tight text-gray-100">
|
||||||
|
{step.primary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{step.detail && (
|
||||||
|
<p className="mt-0.5 text-[11.5px] leading-snug text-gray-500">{step.detail}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
{!plan.honorsTarget && (
|
{!plan.honorsTarget && (
|
||||||
<p className="mt-2.5 border-t border-white/[0.05] pt-2 text-[11px] leading-relaxed text-gray-500">
|
<p className="mt-1 border-t border-white/[0.05] pt-3 text-[11px] leading-relaxed text-gray-500">
|
||||||
There is <span className="text-gray-400">no take-profit</span>. Winners are ridden until the
|
Levels on the chart are screening context, not sell targets.
|
||||||
trailing stop is hit — that’s where the strategy’s edge comes from, so hitting a level
|
|
||||||
below is not a reason to sell. The levels shown below are screening context, not exits.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExitStep {
|
||||||
|
title: string;
|
||||||
|
primary?: string;
|
||||||
|
detail?: string;
|
||||||
|
/** Optional glyph instead of a step number (e.g. clock for time stop). */
|
||||||
|
icon?: string;
|
||||||
|
tone?: 'default' | 'muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(plan: ExitPlan, isLong: boolean): ExitStep[] {
|
||||||
|
const steps: ExitStep[] = [
|
||||||
|
{
|
||||||
|
title: 'Protected by stop',
|
||||||
|
primary: formatPrice(plan.initialStop),
|
||||||
|
detail: `Risk 1R = ${formatPrice(plan.riskPerShare)} per share until the trail is live.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null) {
|
||||||
|
steps.push({
|
||||||
|
title: 'Trail arms',
|
||||||
|
primary: `${isLong ? 'above' : 'below'} ${formatPrice(plan.trailTakesOverAt)}`,
|
||||||
|
detail: 'When the trail would sit beyond the initial stop, it takes over as the floor.',
|
||||||
|
});
|
||||||
|
steps.push({
|
||||||
|
title: 'Then rides the trail',
|
||||||
|
primary: `${formatPrice(plan.trailWidth ?? 0)} · ${plan.trailWidthR.toFixed(1)}R give-back`,
|
||||||
|
detail: isLong
|
||||||
|
? 'Stop = highest close minus trail width. Winners run — no take-profit.'
|
||||||
|
: 'Stop = lowest close plus trail width. Winners run — no take-profit.',
|
||||||
|
});
|
||||||
|
} else if (plan.mode === 'trailing' && plan.trailWidth != null && plan.trailWidthR != null) {
|
||||||
|
steps.push({
|
||||||
|
title: 'Trailing stop',
|
||||||
|
primary: `${formatPrice(plan.trailWidth)} · ${plan.trailWidthR.toFixed(1)}R give-back`,
|
||||||
|
detail: 'Follows price; no fixed take-profit.',
|
||||||
|
});
|
||||||
|
} else if (plan.mode === 'target') {
|
||||||
|
steps.push({
|
||||||
|
title: 'Take profit',
|
||||||
|
primary: 'At the selected level',
|
||||||
|
detail: 'Or exit at the stop if price reverses first.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
title: 'Time stop',
|
||||||
|
primary: `${plan.maxHoldDays} trading days`,
|
||||||
|
detail: 'Flat if still open after the max hold.',
|
||||||
|
tone: 'muted',
|
||||||
|
});
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,19 +70,31 @@ export function ProductionRankStrip({ setup, momentumGate }: ProductionRankStrip
|
|||||||
: null;
|
: null;
|
||||||
const rank = storedRank ?? computedBlend ?? momentum;
|
const rank = storedRank ?? computedBlend ?? momentum;
|
||||||
|
|
||||||
if (rank == null && momentum == null && volatility == null) return null;
|
// Always render when a setup exists. Previously we returned null when ranks
|
||||||
|
// were missing, which hid the entire strip after a single-ticker rescan that
|
||||||
|
// forgot to attach percentiles — leaving no clue why the name dropped out of
|
||||||
|
// qualified trades.
|
||||||
|
if (!setup) return null;
|
||||||
|
|
||||||
|
const ranksMissing = rank == null && momentum == null && volatility == null;
|
||||||
const normalizedRank = clampPercent(rank ?? 0);
|
const normalizedRank = clampPercent(rank ?? 0);
|
||||||
const momentumContribution = hasBlend ? clampPercent(momentum) * MOMENTUM_WEIGHT : normalizedRank;
|
const momentumContribution = hasBlend ? clampPercent(momentum) * MOMENTUM_WEIGHT : (rank != null ? normalizedRank : 0);
|
||||||
const volatilityContribution = hasBlend ? clampPercent(volatility) * VOLATILITY_WEIGHT : 0;
|
const volatilityContribution = hasBlend ? clampPercent(volatility) * VOLATILITY_WEIGHT : 0;
|
||||||
const gateEnabled = momentumGate > 0;
|
const gateEnabled = momentumGate > 0;
|
||||||
const gatePassed = momentum != null && (!gateEnabled || momentum >= momentumGate);
|
const gatePassed = momentum != null && (!gateEnabled || momentum >= momentumGate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="mt-4 border-y border-white/[0.07] py-4"
|
className="mt-6 border-y border-white/[0.07] py-4"
|
||||||
aria-label="Production ranking snapshot"
|
aria-label="Production ranking snapshot"
|
||||||
>
|
>
|
||||||
|
{ranksMissing && (
|
||||||
|
<p className="mb-3 rounded-lg border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-[12px] leading-snug text-amber-100">
|
||||||
|
Residual momentum rank missing on this setup — the activation gate
|
||||||
|
cannot qualify it until ranks are attached. Refresh the ticker (or wait
|
||||||
|
for the daily scan) to recompute universe ranks.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="grid gap-5 lg:grid-cols-[minmax(170px,0.55fr)_minmax(0,1.8fr)] lg:gap-8">
|
<div className="grid gap-5 lg:grid-cols-[minmax(170px,0.55fr)_minmax(0,1.8fr)] lg:gap-8">
|
||||||
<div className="flex items-end justify-between gap-4 lg:block">
|
<div className="flex items-end justify-between gap-4 lg:block">
|
||||||
<div>
|
<div>
|
||||||
@@ -102,10 +114,16 @@ export function ProductionRankStrip({ setup, momentumGate }: ProductionRankStrip
|
|||||||
{rank != null && (
|
{rank != null && (
|
||||||
<p className="text-[11px] text-gray-400">top {topShare(normalizedRank)} of the universe</p>
|
<p className="text-[11px] text-gray-400">top {topShare(normalizedRank)} of the universe</p>
|
||||||
)}
|
)}
|
||||||
{momentum != null && gateEnabled && (
|
{momentum == null && gateEnabled ? (
|
||||||
|
<p className="mt-0.5 text-[10px] text-amber-300">
|
||||||
|
momentum gate: no residual rank
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
momentum != null && gateEnabled && (
|
||||||
<p className={`mt-0.5 text-[10px] ${gatePassed ? 'text-blue-300' : 'text-red-300'}`}>
|
<p className={`mt-0.5 text-[10px] ${gatePassed ? 'text-blue-300' : 'text-red-300'}`}>
|
||||||
momentum gate {gatePassed ? 'passed' : 'not passed'}
|
momentum gate {gatePassed ? 'passed' : 'not passed'}
|
||||||
</p>
|
</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -302,13 +302,20 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
|||||||
preferred
|
preferred
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{setup.momentum_percentile != null && (
|
{setup.momentum_percentile != null ? (
|
||||||
<span
|
<span
|
||||||
className="num rounded-full border border-blue-400/25 bg-blue-400/10 px-2.5 py-0.5 text-[11px] text-blue-200"
|
className="num rounded-full border border-blue-400/25 bg-blue-400/10 px-2.5 py-0.5 text-[11px] text-blue-200"
|
||||||
title="Residual 12-1 month momentum percentile across the universe. This is the actual signal — the reason the ticker was selected at all."
|
title="Residual 12-1 month momentum percentile across the universe. This is the actual signal — the reason the ticker was selected at all."
|
||||||
>
|
>
|
||||||
momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}%
|
momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}%
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="num rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-0.5 text-[11px] text-amber-200"
|
||||||
|
title="No residual 12-1 momentum percentile on this setup. Missing ranks do not clear the activation gate."
|
||||||
|
>
|
||||||
|
no residual rank
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
|
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -192,7 +192,9 @@ export default function StandingMatrix({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
No active setup, so this ticker isn’t ranked on the momentum axis yet. Run the scanner to place it.
|
{composite != null && momentum == null
|
||||||
|
? 'Setup is present, but residual momentum rank is missing — the activation gate treats unranked setups as not qualified. Refresh this ticker (or wait for the daily scan) so universe ranks are attached.'
|
||||||
|
: 'No active setup, so this ticker isn’t ranked on the momentum axis yet. Run the scanner to place it.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -171,9 +171,10 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
|
|||||||
R:R and reach probability are gate inputs computed from a GTL
|
R:R and reach probability are gate inputs computed from a GTL
|
||||||
proposal the trade never exits at — they get quiet treatment. */}
|
proposal the trade never exits at — they get quiet treatment. */}
|
||||||
<div className="flex items-start gap-10 text-right">
|
<div className="flex items-start gap-10 text-right">
|
||||||
{setup.momentum_percentile != null && (
|
|
||||||
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
|
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
|
||||||
<p className="section-index">residual momentum</p>
|
<p className="section-index">residual momentum</p>
|
||||||
|
{setup.momentum_percentile != null ? (
|
||||||
|
<>
|
||||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
||||||
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
|
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
|
||||||
<span className="text-lg text-gray-400">%</span>
|
<span className="text-lg text-gray-400">%</span>
|
||||||
@@ -184,8 +185,14 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
|
|||||||
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
|
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-display mt-1 text-2xl font-semibold text-amber-200">missing</p>
|
||||||
|
<p className="mt-1 text-[11px] text-gray-500">no residual rank · cannot qualify</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="max-w-[13rem]"
|
className="max-w-[13rem]"
|
||||||
title="Gate metrics. The reward/risk and reach probability of the headline Gate Target Ladder proposal are what admitted this setup. The trade does NOT exit there — it exits on the trailing stop."
|
title="Gate metrics. The reward/risk and reach probability of the headline Gate Target Ladder proposal are what admitted this setup. The trade does NOT exit there — it exits on the trailing stop."
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ export default function TickerDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(longSetup || shortSetup) && (
|
{(longSetup || shortSetup) && (
|
||||||
<div className="mt-6 border-t border-white/[0.06] pt-5">
|
<div className="mt-8 border-t border-white/[0.06] pt-7">
|
||||||
<RecommendationPanel
|
<RecommendationPanel
|
||||||
frameless
|
frameless
|
||||||
symbol={symbol}
|
symbol={symbol}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Single-ticker activation ranks: manual refresh must attach residual ranks.
|
||||||
|
|
||||||
|
A daily ``scan_all`` path ranks the universe and stamps each setup. Manual
|
||||||
|
``/ingestion/fetch`` re-scans one symbol; without the same stamps the new
|
||||||
|
setup has ``momentum_percentile=None`` and fails the activation gate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.models.trade_setup import TradeSetup
|
||||||
|
from app.services import rr_scanner_service
|
||||||
|
from tests.conftest import _test_session_factory # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def session():
|
||||||
|
async with _test_session_factory() as s:
|
||||||
|
yield s
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_ranks_uses_fresh_universe_rank(session, monkeypatch):
|
||||||
|
session.add(Ticker(symbol="AAA"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _fake_ranks(db):
|
||||||
|
return {
|
||||||
|
"AAA": {
|
||||||
|
"momentum_percentile": 91.0,
|
||||||
|
"strategy_rank": 88.5,
|
||||||
|
"volatility_percentile": 70.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.momentum_service.compute_activation_ranks",
|
||||||
|
_fake_ranks,
|
||||||
|
)
|
||||||
|
|
||||||
|
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "aaa")
|
||||||
|
assert ranks["momentum_percentile"] == 91.0
|
||||||
|
assert ranks["strategy_rank"] == 88.5
|
||||||
|
assert ranks["volatility_percentile"] == 70.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_ranks_falls_back_to_previous_setup(session, monkeypatch):
|
||||||
|
ticker = Ticker(symbol="BBB")
|
||||||
|
session.add(ticker)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=110.0,
|
||||||
|
rr_ratio=2.0,
|
||||||
|
composite_score=60.0,
|
||||||
|
momentum_percentile=84.0,
|
||||||
|
strategy_rank=80.0,
|
||||||
|
volatility_percentile=55.0,
|
||||||
|
detected_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _empty_ranks(db):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.momentum_service.compute_activation_ranks",
|
||||||
|
_empty_ranks,
|
||||||
|
)
|
||||||
|
|
||||||
|
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "BBB")
|
||||||
|
assert ranks["momentum_percentile"] == 84.0
|
||||||
|
assert ranks["strategy_rank"] == 80.0
|
||||||
|
assert ranks["volatility_percentile"] == 55.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_ranks_skips_null_prior_setups(session, monkeypatch):
|
||||||
|
"""Broken prior setups (null percentile) must not block a still-older ranked row."""
|
||||||
|
ticker = Ticker(symbol="CCC")
|
||||||
|
session.add(ticker)
|
||||||
|
await session.flush()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
session.add_all([
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=110.0,
|
||||||
|
rr_ratio=2.0,
|
||||||
|
composite_score=60.0,
|
||||||
|
momentum_percentile=93.0,
|
||||||
|
strategy_rank=90.0,
|
||||||
|
volatility_percentile=40.0,
|
||||||
|
detected_at=now.replace(year=now.year - 1) if now.year > 2000 else now,
|
||||||
|
),
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=101.0,
|
||||||
|
stop_loss=96.0,
|
||||||
|
target=111.0,
|
||||||
|
rr_ratio=2.1,
|
||||||
|
composite_score=61.0,
|
||||||
|
momentum_percentile=None,
|
||||||
|
strategy_rank=None,
|
||||||
|
volatility_percentile=None,
|
||||||
|
detected_at=now,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _empty_ranks(db):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.momentum_service.compute_activation_ranks",
|
||||||
|
_empty_ranks,
|
||||||
|
)
|
||||||
|
|
||||||
|
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "CCC")
|
||||||
|
assert ranks["momentum_percentile"] == 93.0
|
||||||
|
assert ranks["strategy_rank"] == 90.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_ranks_returns_empty_when_unavailable(session, monkeypatch):
|
||||||
|
session.add(Ticker(symbol="DDD"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _boom(db):
|
||||||
|
raise RuntimeError("ranker down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.momentum_service.compute_activation_ranks",
|
||||||
|
_boom,
|
||||||
|
)
|
||||||
|
|
||||||
|
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "DDD")
|
||||||
|
assert ranks == {
|
||||||
|
"momentum_percentile": None,
|
||||||
|
"strategy_rank": None,
|
||||||
|
"volatility_percentile": None,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user