fix: decouple the sentiment weight from the base mix in the weights form
Sentiment is now a signed adjustment (± points on top of the base), not part of the averaged dimensions — but the weights form still squeezed all five sliders to sum to 100%, so dragging sentiment rebalanced the base and you couldn't set a clean ±N. Now the four base dimensions normalize among themselves (share shown as %), and sentiment is its own "influence (± points)" control passed through raw (slider 10 → weight 0.10 → ±10), independent of the base. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
import { useState, useMemo, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useUpdateWeights } from '../../hooks/useScores';
|
import { useUpdateWeights } from '../../hooks/useScores';
|
||||||
|
|
||||||
interface WeightsFormProps {
|
interface WeightsFormProps {
|
||||||
weights: Record<string, number>;
|
weights: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SENTIMENT = 'sentiment';
|
||||||
|
|
||||||
export function WeightsForm({ weights }: WeightsFormProps) {
|
export function WeightsForm({ weights }: WeightsFormProps) {
|
||||||
// Convert API decimal weights (0-1) to 0-100 integer scale on mount
|
// API decimal weights (0-1) → 0-100 integer sliders. For the base dimensions
|
||||||
|
// that's their share of the weighted average; for sentiment it's the ± points
|
||||||
|
// it can move the composite (MAX_ADJ), decoupled from the base mix.
|
||||||
const [sliderValues, setSliderValues] = useState<Record<string, number>>(() =>
|
const [sliderValues, setSliderValues] = useState<Record<string, number>>(() =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(weights).map(([key, w]) => [key, Math.round(w * 100)])
|
Object.entries(weights).map(([key, w]) => [key, Math.round(w * 100)])
|
||||||
@@ -14,10 +18,10 @@ export function WeightsForm({ weights }: WeightsFormProps) {
|
|||||||
);
|
);
|
||||||
const updateWeights = useUpdateWeights();
|
const updateWeights = useUpdateWeights();
|
||||||
|
|
||||||
const allZero = useMemo(
|
const baseKeys = Object.keys(weights).filter((k) => k !== SENTIMENT);
|
||||||
() => Object.values(sliderValues).every((v) => v === 0),
|
const hasSentiment = SENTIMENT in weights;
|
||||||
[sliderValues]
|
const baseTotal = baseKeys.reduce((sum, k) => sum + (sliderValues[k] ?? 0), 0);
|
||||||
);
|
const sentimentPts = sliderValues[SENTIMENT] ?? 0;
|
||||||
|
|
||||||
const handleChange = (key: string, value: string) => {
|
const handleChange = (key: string, value: string) => {
|
||||||
const num = parseInt(value, 10);
|
const num = parseInt(value, 10);
|
||||||
@@ -26,24 +30,35 @@ export function WeightsForm({ weights }: WeightsFormProps) {
|
|||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (allZero) return;
|
if (baseTotal === 0) return;
|
||||||
|
|
||||||
const total = Object.values(sliderValues).reduce((sum, v) => sum + v, 0);
|
// Base dimensions normalize among themselves; sentiment passes through raw
|
||||||
const normalized = Object.fromEntries(
|
// (slider value / 100) so it stays independent of the base.
|
||||||
Object.entries(sliderValues).map(([key, v]) => [key, v / total])
|
const payload: Record<string, number> = Object.fromEntries(
|
||||||
|
baseKeys.map((key) => [key, (sliderValues[key] ?? 0) / baseTotal])
|
||||||
);
|
);
|
||||||
updateWeights.mutate(normalized);
|
if (hasSentiment) payload[SENTIMENT] = sentimentPts / 100;
|
||||||
|
updateWeights.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="glass p-5">
|
<form onSubmit={handleSubmit} className="glass p-5">
|
||||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-500">
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-widest text-gray-500">
|
||||||
Scoring Weights
|
Scoring Weights
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="mb-4 text-[11px] text-gray-500">
|
||||||
|
The base dimensions are a weighted average (shares normalize to 100%). Sentiment is applied
|
||||||
|
separately as a signed adjustment on top.
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{Object.keys(weights).map((key) => (
|
{baseKeys.map((key) => (
|
||||||
<label key={key} className="flex flex-col gap-1.5">
|
<label key={key} className="flex flex-col gap-1.5">
|
||||||
<span className="text-xs text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
|
<span className="text-xs text-gray-400 capitalize">
|
||||||
|
{key.replace(/_/g, ' ')}
|
||||||
|
<span className="ml-1 text-gray-600">
|
||||||
|
· {baseTotal > 0 ? Math.round(((sliderValues[key] ?? 0) / baseTotal) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -61,14 +76,39 @@ export function WeightsForm({ weights }: WeightsFormProps) {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{allZero && (
|
|
||||||
<p className="mt-3 text-xs text-red-400">
|
{hasSentiment && (
|
||||||
At least one weight must be greater than zero
|
<div className="mt-4 border-t border-white/[0.06] pt-4">
|
||||||
</p>
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs text-gray-400">Sentiment influence (± points)</span>
|
||||||
|
<div className="flex items-center gap-2 sm:max-w-sm">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={30}
|
||||||
|
step={1}
|
||||||
|
value={sentimentPts}
|
||||||
|
onChange={(e) => handleChange(SENTIMENT, e.target.value)}
|
||||||
|
className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 accent-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="min-w-[3ch] text-right text-sm font-medium text-gray-300">
|
||||||
|
±{sentimentPts}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-gray-600">
|
||||||
|
Max points a bullish (or bearish) read moves the composite, scaled by confidence.
|
||||||
|
Doesn’t change the base mix. 0 = ignore sentiment.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{baseTotal === 0 && (
|
||||||
|
<p className="mt-3 text-xs text-red-400">At least one base weight must be greater than zero</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateWeights.isPending || allZero}
|
disabled={updateWeights.isPending || baseTotal === 0}
|
||||||
className="mt-4 btn-primary px-4 py-2 text-sm disabled:opacity-50"
|
className="mt-4 btn-primary px-4 py-2 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
|
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user