add position-size calculator to the recommendation panel
Risk-based sizing on each setup card: shares = floor((account × risk%) / |entry − stop|), with position value and dollars-at-risk. Account size and per-trade risk % are editable inline and persisted in localStorage. Flags when a position would exceed the account (needs margin). Frontend-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { TradeSetup } from '../../lib/types';
|
||||
import { formatPrice, formatPercent } from '../../lib/format';
|
||||
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
|
||||
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
|
||||
import { positionSize } from '../../lib/position';
|
||||
|
||||
interface RecommendationPanelProps {
|
||||
symbol: string;
|
||||
@@ -83,7 +85,7 @@ function TargetTable({ setup }: { setup: TradeSetup }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SetupCard({ setup, action, currentPrice }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number }) {
|
||||
function SetupCard({ setup, action, currentPrice, risk }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings }) {
|
||||
if (!setup) {
|
||||
return (
|
||||
<div className="glass-sm p-4 text-xs text-gray-500">
|
||||
@@ -94,6 +96,7 @@ function SetupCard({ setup, action, currentPrice }: { setup?: TradeSetup; action
|
||||
|
||||
const recommended = isRecommended(setup, action);
|
||||
const drift = entryDrift(setup, currentPrice);
|
||||
const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -132,6 +135,33 @@ function SetupCard({ setup, action, currentPrice }: { setup?: TradeSetup; action
|
||||
<div className="text-gray-500">R:R</div><div className="font-mono text-gray-200">{setup.rr_ratio.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
{sizing ? (
|
||||
<div className="rounded border border-white/[0.06] bg-white/[0.02] p-2.5 text-xs">
|
||||
<p className="mb-1.5 text-[10px] uppercase tracking-wider text-gray-500">
|
||||
Position size · {risk.riskPct}% of {formatPrice(risk.accountSize)}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div className="font-mono text-sm text-gray-100">{sizing.shares}</div>
|
||||
<div className="text-[10px] text-gray-500">shares</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-mono text-sm ${sizing.exceedsAccount ? 'text-amber-400' : 'text-gray-100'}`}>{formatPrice(sizing.positionValue)}</div>
|
||||
<div className="text-[10px] text-gray-500">position</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-sm text-gray-100">{formatPrice(sizing.dollarRisk)}</div>
|
||||
<div className="text-[10px] text-gray-500">at risk</div>
|
||||
</div>
|
||||
</div>
|
||||
{sizing.exceedsAccount && (
|
||||
<p className="mt-1.5 text-[10px] text-amber-400">Position exceeds account — needs margin.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-gray-600">Set account size below to size this trade.</p>
|
||||
)}
|
||||
|
||||
<TargetTable setup={setup} />
|
||||
|
||||
{setup.conflict_flags.length > 0 && (
|
||||
@@ -143,7 +173,39 @@ function SetupCard({ setup, action, currentPrice }: { setup?: TradeSetup; action
|
||||
);
|
||||
}
|
||||
|
||||
function RiskSettingsBar({ risk, update }: { risk: RiskSettings; update: (p: Partial<RiskSettings>) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
||||
<span className="text-gray-500">Risk settings:</span>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500">Account $</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={risk.accountSize}
|
||||
onChange={(e) => update({ accountSize: Number(e.target.value) })}
|
||||
className="w-28 input-glass px-2 py-1 font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500">Risk %</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.25}
|
||||
value={risk.riskPct}
|
||||
onChange={(e) => update({ riskPct: Number(e.target.value) })}
|
||||
className="w-20 input-glass px-2 py-1 font-mono"
|
||||
/>
|
||||
</label>
|
||||
<span className="text-[10px] text-gray-600">saved in this browser</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice }: RecommendationPanelProps) {
|
||||
const { settings: risk, update: updateRisk } = useRiskSettings();
|
||||
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
|
||||
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
|
||||
const preferredDirection = recommendationActionDirection(action);
|
||||
@@ -185,9 +247,11 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
||||
<p className="text-sm text-gray-300">{summary.reasoning}</p>
|
||||
)}
|
||||
|
||||
<RiskSettingsBar risk={risk} update={updateRisk} />
|
||||
|
||||
{preferredDirection !== 'neutral' && preferredSetup ? (
|
||||
<div className="space-y-3">
|
||||
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} />
|
||||
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
||||
|
||||
{alternativeSetup && (
|
||||
<details className="glass-sm p-3">
|
||||
@@ -195,15 +259,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
||||
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} />
|
||||
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} />
|
||||
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} />
|
||||
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
||||
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface RiskSettings {
|
||||
accountSize: number;
|
||||
riskPct: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'risk-settings';
|
||||
const DEFAULTS: RiskSettings = { accountSize: 10000, riskPct: 1 };
|
||||
|
||||
/** Account size + per-trade risk %, persisted in localStorage (per browser). */
|
||||
export function useRiskSettings() {
|
||||
const [settings, setSettings] = useState<RiskSettings>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return { ...DEFAULTS, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
/* ignore malformed storage */
|
||||
}
|
||||
return DEFAULTS;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
/* ignore quota/availability errors */
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Partial<RiskSettings>) => setSettings((s) => ({ ...s, ...patch })),
|
||||
[],
|
||||
);
|
||||
|
||||
return { settings, update };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface PositionSize {
|
||||
shares: number;
|
||||
riskPerShare: number;
|
||||
dollarRisk: number;
|
||||
positionValue: number;
|
||||
/** Position value exceeds the account → needs margin / not affordable in cash. */
|
||||
exceedsAccount: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk-based position sizing. Risk a fixed % of the account per trade; the stop
|
||||
* distance sets how many shares that budget buys:
|
||||
* shares = floor((account × risk%) / |entry − stop|)
|
||||
* Returns null when inputs are unusable (no account, no risk, zero stop width).
|
||||
*/
|
||||
export function positionSize(
|
||||
accountSize: number,
|
||||
riskPct: number,
|
||||
entry: number,
|
||||
stop: number,
|
||||
): PositionSize | null {
|
||||
const riskPerShare = Math.abs(entry - stop);
|
||||
if (!(accountSize > 0) || !(riskPct > 0) || !(riskPerShare > 0) || !(entry > 0)) {
|
||||
return null;
|
||||
}
|
||||
const budget = accountSize * (riskPct / 100);
|
||||
const shares = Math.floor(budget / riskPerShare);
|
||||
const dollarRisk = shares * riskPerShare;
|
||||
const positionValue = shares * entry;
|
||||
return {
|
||||
shares,
|
||||
riskPerShare,
|
||||
dollarRisk,
|
||||
positionValue,
|
||||
exceedsAccount: positionValue > accountSize,
|
||||
};
|
||||
}
|
||||
@@ -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/jobs.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/alertsettings.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/dropdown.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"}
|
||||
{"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/jobs.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/alertsettings.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/dropdown.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/userisksettings.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/position.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"}
|
||||
Reference in New Issue
Block a user