Improve open-position mini chart readability.
Center young-trade entry, draw full-width stop/now/gate, color the path vs entry, and show the trail only after it ratchets.
This commit is contained in:
@@ -337,6 +337,10 @@ function buildStopPath(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Violet used for Gate on the ticker chart — keep the overview chart in step. */
|
||||||
|
const GATE_STROKE = 'rgba(139, 92, 246, 0.75)';
|
||||||
|
const GATE_LABEL = 'rgba(196, 181, 253, 0.95)';
|
||||||
|
|
||||||
export function TradeChart({
|
export function TradeChart({
|
||||||
direction,
|
direction,
|
||||||
entry,
|
entry,
|
||||||
@@ -347,31 +351,56 @@ export function TradeChart({
|
|||||||
exitMode = 'atr_trailing',
|
exitMode = 'atr_trailing',
|
||||||
atrMultiplier = 3,
|
atrMultiplier = 3,
|
||||||
trailingPct = 12,
|
trailingPct = 12,
|
||||||
|
currentPrice,
|
||||||
}: {
|
}: {
|
||||||
direction: string;
|
direction: string;
|
||||||
entry: number;
|
entry: number;
|
||||||
/** Hard stop from the setup (floor for the trail). */
|
/** Hard stop from the setup (floor for the trail). */
|
||||||
initialStop: number;
|
initialStop: number;
|
||||||
|
/** Screening gate level (not the live exit under trailing modes). */
|
||||||
target: number;
|
target: number;
|
||||||
bars: OHLCVBar[];
|
bars: OHLCVBar[];
|
||||||
openedAt: string;
|
openedAt: string;
|
||||||
exitMode?: 'time' | 'trailing' | 'atr_trailing' | 'target';
|
exitMode?: 'time' | 'trailing' | 'atr_trailing' | 'target';
|
||||||
atrMultiplier?: number;
|
atrMultiplier?: number;
|
||||||
trailingPct?: number;
|
trailingPct?: number;
|
||||||
|
/** Live mark; falls back to the latest close in the window. */
|
||||||
|
currentPrice?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const openedDate = openedAt.slice(0, 10);
|
const openedDate = openedAt.slice(0, 10);
|
||||||
const firstIdx = bars.findIndex((b) => b.date.slice(0, 10) >= openedDate);
|
const absEntry = bars.findIndex((b) => b.date.slice(0, 10) >= openedDate);
|
||||||
// Fresh trades have few bars since entry — pad with context so the chart
|
if (bars.length < 2) return null;
|
||||||
// still reads (gray before entry, colored after).
|
const entryAbs = absEntry === -1 ? bars.length - 1 : absEntry;
|
||||||
const CONTEXT_BARS = 10;
|
const postCount = bars.length - entryAbs; // bars from entry through latest
|
||||||
const start = firstIdx === -1
|
|
||||||
? Math.max(0, bars.length - CONTEXT_BARS)
|
// Fixed virtual window so a brand-new trade doesn't glue entry to the right
|
||||||
: Math.max(0, firstIdx - CONTEXT_BARS);
|
// edge. Entry sits at the middle until the trade fills the right half; then
|
||||||
const window = bars.slice(start);
|
// it wanders left as more post-entry bars arrive.
|
||||||
const series = window.map((b) => b.close);
|
const WINDOW = 21;
|
||||||
const entryIdx = firstIdx === -1 ? series.length - 1 : firstIdx - start;
|
const MID = 10;
|
||||||
|
let start: number;
|
||||||
|
let entryIdx: number;
|
||||||
|
if (postCount <= MID + 1) {
|
||||||
|
start = Math.max(0, entryAbs - MID);
|
||||||
|
entryIdx = entryAbs - start;
|
||||||
|
} else {
|
||||||
|
// Enough history: keep the latest WINDOW bars; entry falls where it falls.
|
||||||
|
start = Math.max(0, bars.length - WINDOW);
|
||||||
|
entryIdx = entryAbs - start;
|
||||||
|
}
|
||||||
|
const windowBars = bars.slice(start);
|
||||||
|
const series = windowBars.map((b) => b.close);
|
||||||
if (series.length < 2) return null;
|
if (series.length < 2) return null;
|
||||||
|
|
||||||
|
// Slot map: young trades keep entry near MID with empty space to the right.
|
||||||
|
const lastIdx = series.length - 1;
|
||||||
|
const young = postCount <= MID + 1;
|
||||||
|
const slotOf = (i: number) => {
|
||||||
|
if (!young) return i;
|
||||||
|
return i + (MID - entryIdx);
|
||||||
|
};
|
||||||
|
const maxSlot = young ? Math.max(WINDOW - 1, slotOf(lastIdx)) : lastIdx;
|
||||||
|
|
||||||
const trailMode: 'atr_trailing' | 'trailing' | 'fixed' =
|
const trailMode: 'atr_trailing' | 'trailing' | 'fixed' =
|
||||||
exitMode === 'atr_trailing' || exitMode === 'trailing' ? exitMode : 'fixed';
|
exitMode === 'atr_trailing' || exitMode === 'trailing' ? exitMode : 'fixed';
|
||||||
const stopPathFull = buildStopPath(
|
const stopPathFull = buildStopPath(
|
||||||
@@ -386,57 +415,87 @@ export function TradeChart({
|
|||||||
);
|
);
|
||||||
const stopPath = stopPathFull.slice(start);
|
const stopPath = stopPathFull.slice(start);
|
||||||
const trailActive = trailMode !== 'fixed';
|
const trailActive = trailMode !== 'fixed';
|
||||||
// Target is a screening artifact under trailing policies — only draw when
|
// Gate is always drawn as structure (violet). Under target-exit mode it is
|
||||||
// it is an actual exit.
|
// the real exit; otherwise it's screening context.
|
||||||
const showTarget = exitMode === 'target';
|
const gateIsExit = exitMode === 'target';
|
||||||
|
|
||||||
|
const now = currentPrice != null && Number.isFinite(currentPrice)
|
||||||
|
? currentPrice
|
||||||
|
: series[lastIdx];
|
||||||
|
|
||||||
const w = 560; const h = 150;
|
const w = 560; const h = 150;
|
||||||
const padL = 8; const padR = 96; const padT = 10; const padB = 12;
|
const padL = 8; const padR = 96; const padT = 10; const padB = 12;
|
||||||
const isShort = direction === 'short';
|
const isShort = direction === 'short';
|
||||||
const vals = [...series, ...stopPath, entry, initialStop];
|
// Always include the gate so the top of the chart isn't "open" — it's
|
||||||
if (showTarget) vals.push(target);
|
// screening structure, same as the ticker chart's Gate line.
|
||||||
|
const vals = [...series, ...stopPath, entry, initialStop, now, target];
|
||||||
let lo = Math.min(...vals);
|
let lo = Math.min(...vals);
|
||||||
let hi = Math.max(...vals);
|
let hi = Math.max(...vals);
|
||||||
const range = hi - lo || 1;
|
const pad = (hi - lo) * 0.07 || 0.5;
|
||||||
if (showTarget) {
|
|
||||||
const targetFits = isShort ? lo - target <= range * 0.9 : target - hi <= range * 0.9;
|
|
||||||
if (targetFits) { lo = Math.min(lo, target); hi = Math.max(hi, target); }
|
|
||||||
}
|
|
||||||
const pad = (hi - lo) * 0.07;
|
|
||||||
lo -= pad; hi += pad;
|
lo -= pad; hi += pad;
|
||||||
const plotW = w - padL - padR;
|
const plotW = w - padL - padR;
|
||||||
const plotH = h - padT - padB;
|
const plotH = h - padT - padB;
|
||||||
const px = (i: number) => padL + (i / (series.length - 1)) * plotW;
|
const px = (i: number) => padL + (slotOf(i) / maxSlot) * plotW;
|
||||||
const py = (v: number) => padT + plotH - ((v - lo) / (hi - lo)) * plotH;
|
const py = (v: number) => padT + plotH - ((v - lo) / (hi - lo)) * plotH;
|
||||||
const seg = (from: number, to: number) =>
|
|
||||||
series
|
const inProfit = (price: number) => (isShort ? price <= entry : price >= entry);
|
||||||
.slice(from, to + 1)
|
|
||||||
.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(from + i).toFixed(1)},${py(v).toFixed(1)}`)
|
// Context (pre-entry) in muted ink.
|
||||||
.join(' ');
|
const contextPath = entryIdx > 0
|
||||||
// Step-style trail path so ratchet days read as jumps, not smoothed slopes.
|
? series
|
||||||
const stopSeg = (from: number, to: number) => {
|
.slice(0, entryIdx + 1)
|
||||||
if (to < from) return '';
|
.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`)
|
||||||
let d = `M${px(from).toFixed(1)},${py(stopPath[from]).toFixed(1)}`;
|
.join(' ')
|
||||||
for (let i = from + 1; i <= to; i++) {
|
: null;
|
||||||
d += `H${px(i).toFixed(1)}V${py(stopPath[i]).toFixed(1)}`;
|
|
||||||
|
// Trade path: color each segment by whether the *end* bar is through entry
|
||||||
|
// (not one color for the whole trade).
|
||||||
|
const tradeSegs: { d: string; profit: boolean }[] = [];
|
||||||
|
for (let i = entryIdx; i < lastIdx; i++) {
|
||||||
|
const profit = inProfit(series[i + 1]);
|
||||||
|
const d = `M${px(i).toFixed(1)},${py(series[i]).toFixed(1)}L${px(i + 1).toFixed(1)},${py(series[i + 1]).toFixed(1)}`;
|
||||||
|
const prev = tradeSegs[tradeSegs.length - 1];
|
||||||
|
if (prev && prev.profit === profit) prev.d += `L${px(i + 1).toFixed(1)},${py(series[i + 1]).toFixed(1)}`;
|
||||||
|
else tradeSegs.push({ d, profit });
|
||||||
}
|
}
|
||||||
return d;
|
|
||||||
};
|
// Trail path from entry → now (stepped). Always drawn so young trades still
|
||||||
const contextPath = entryIdx > 0 ? seg(0, entryIdx) : null;
|
// show a stop even before the trail has ratcheted.
|
||||||
const tradePath = seg(entryIdx, series.length - 1);
|
let trailPath = '';
|
||||||
const trailPath = stopSeg(entryIdx, series.length - 1);
|
if (entryIdx <= lastIdx) {
|
||||||
const last = series[series.length - 1];
|
trailPath = `M${px(entryIdx).toFixed(1)},${py(stopPath[entryIdx]).toFixed(1)}`;
|
||||||
const liveStop = stopPath[stopPath.length - 1] ?? initialStop;
|
for (let i = entryIdx + 1; i <= lastIdx; i++) {
|
||||||
const trailMoved = Math.abs(liveStop - initialStop) > 1e-6;
|
trailPath += `H${px(i).toFixed(1)}V${py(stopPath[i]).toFixed(1)}`;
|
||||||
const perShare = isShort ? entry - last : last - entry;
|
}
|
||||||
const col = perShare >= 0 ? 'var(--up)' : 'var(--down)';
|
}
|
||||||
|
|
||||||
|
const liveStop = stopPath[lastIdx] ?? initialStop;
|
||||||
|
const trailMoved = Math.abs(liveStop - initialStop) > 1e-6 * Math.max(1, Math.abs(entry));
|
||||||
|
const nowProfit = inProfit(now);
|
||||||
|
const nowCol = nowProfit ? 'var(--up)' : 'var(--down)';
|
||||||
const labelX = w - padR + 10;
|
const labelX = w - padR + 10;
|
||||||
const entryY = py(entry);
|
const entryY = py(entry);
|
||||||
let stopLabelY = py(liveStop);
|
const nowY = py(now);
|
||||||
if (Math.abs(stopLabelY - entryY) < 11) {
|
const stopY = py(initialStop);
|
||||||
stopLabelY = entryY + (stopLabelY >= entryY ? 11 : -11);
|
let trailLabelY = py(liveStop);
|
||||||
|
const gateY = py(target);
|
||||||
|
|
||||||
|
// Nudge overlapping labels on the right rail.
|
||||||
|
const labelYs: { key: string; y: number }[] = [
|
||||||
|
{ key: 'entry', y: entryY },
|
||||||
|
{ key: 'now', y: nowY },
|
||||||
|
{ key: 'stop', y: stopY },
|
||||||
|
{ key: 'gate', y: gateY },
|
||||||
|
];
|
||||||
|
if (trailActive && trailMoved) labelYs.push({ key: 'trail', y: trailLabelY });
|
||||||
|
labelYs.sort((a, b) => a.y - b.y);
|
||||||
|
for (let i = 1; i < labelYs.length; i++) {
|
||||||
|
if (labelYs[i].y - labelYs[i - 1].y < 11) {
|
||||||
|
labelYs[i].y = labelYs[i - 1].y + 11;
|
||||||
}
|
}
|
||||||
const stopLabel = trailActive && trailMoved ? 'trail' : 'stop';
|
}
|
||||||
|
const ly = Object.fromEntries(labelYs.map((l) => [l.key, l.y]));
|
||||||
|
if (ly.trail != null) trailLabelY = ly.trail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -444,30 +503,70 @@ export function TradeChart({
|
|||||||
className="hz-tradechart"
|
className="hz-tradechart"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={
|
aria-label={
|
||||||
`Price since entry ${fmt(entry)}, now ${fmt(last)}, ${stopLabel} ${fmt(liveStop)}`
|
`Price since entry ${fmt(entry)}, now ${fmt(now)}, stop ${fmt(initialStop)}`
|
||||||
+ (showTarget ? `, target ${fmt(target)}` : '')
|
+ (trailMoved ? `, trail ${fmt(liveStop)}` : '')
|
||||||
|
+ `, gate ${fmt(target)}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Initial hard stop as a quiet dashed reference once the trail has lifted */}
|
{/* Full-width hard stop — always visible, including for day-0 trades. */}
|
||||||
{trailActive && trailMoved && (
|
|
||||||
<>
|
|
||||||
<line
|
<line
|
||||||
x1={padL}
|
x1={padL}
|
||||||
x2={w - padR}
|
x2={w - padR}
|
||||||
y1={py(initialStop)}
|
y1={stopY}
|
||||||
y2={py(initialStop)}
|
y2={stopY}
|
||||||
stroke="var(--down)"
|
stroke="var(--down)"
|
||||||
strokeWidth="1"
|
strokeWidth="1.25"
|
||||||
strokeDasharray="3 3"
|
opacity="0.55"
|
||||||
opacity="0.35"
|
|
||||||
/>
|
/>
|
||||||
</>
|
<text x={labelX} y={(ly.stop ?? stopY) + 3.5} className="hz-lvl hz-lvl-down">
|
||||||
)}
|
stop {fmt(initialStop)}
|
||||||
<line x1={padL} x2={w - padR} y1={entryY} y2={entryY} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
|
</text>
|
||||||
<text x={labelX} y={entryY + 3.5} className="hz-lvl">entry {fmt(entry)}</text>
|
|
||||||
|
|
||||||
{/* Moving trail (or flat initial stop when no trailing policy) */}
|
{/* Gate (screening level) — violet, matches ticker chart */}
|
||||||
{trailPath && (
|
<line
|
||||||
|
x1={padL}
|
||||||
|
x2={w - padR}
|
||||||
|
y1={py(target)}
|
||||||
|
y2={py(target)}
|
||||||
|
stroke={GATE_STROKE}
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeDasharray={gateIsExit ? undefined : '4 3'}
|
||||||
|
opacity="0.85"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={labelX}
|
||||||
|
y={(ly.gate ?? gateY) + 3.5}
|
||||||
|
className="hz-lvl"
|
||||||
|
style={{ fill: GATE_LABEL }}
|
||||||
|
>
|
||||||
|
gate {fmt(target)}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Entry */}
|
||||||
|
<line x1={padL} x2={w - padR} y1={entryY} y2={entryY} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
|
||||||
|
<text x={labelX} y={(ly.entry ?? entryY) + 3.5} className="hz-lvl">entry {fmt(entry)}</text>
|
||||||
|
|
||||||
|
{/* Now (current price) */}
|
||||||
|
<line
|
||||||
|
x1={padL}
|
||||||
|
x2={w - padR}
|
||||||
|
y1={nowY}
|
||||||
|
y2={nowY}
|
||||||
|
stroke={nowCol}
|
||||||
|
strokeWidth="1"
|
||||||
|
opacity="0.45"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={labelX}
|
||||||
|
y={(ly.now ?? nowY) + 3.5}
|
||||||
|
className="hz-lvl"
|
||||||
|
style={{ fill: nowProfit ? 'var(--up-text)' : 'var(--down-text)' }}
|
||||||
|
>
|
||||||
|
now {fmt(now)}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Trail path from entry (stepped) — only once it has ratcheted off the hard stop */}
|
||||||
|
{trailActive && trailMoved && trailPath && (
|
||||||
<path
|
<path
|
||||||
d={trailPath}
|
d={trailPath}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -475,41 +574,35 @@ export function TradeChart({
|
|||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
opacity="0.9"
|
opacity="0.95"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<text x={labelX} y={stopLabelY + 3.5} className="hz-lvl hz-lvl-down">
|
{trailActive && trailMoved && (
|
||||||
{stopLabel} {fmt(liveStop)}
|
<text x={labelX} y={trailLabelY + 3.5} className="hz-lvl hz-lvl-down">
|
||||||
|
trail {fmt(liveStop)}
|
||||||
</text>
|
</text>
|
||||||
|
)}
|
||||||
{showTarget && (() => {
|
|
||||||
const tFits = isShort
|
|
||||||
? py(target) <= h - padB && py(target) >= padT
|
|
||||||
: py(target) <= h - padB && py(target) >= padT;
|
|
||||||
const inRange = isShort
|
|
||||||
? lo - target <= (hi - lo) * 2
|
|
||||||
: target - hi <= (hi - lo) * 2;
|
|
||||||
if (inRange && tFits) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<line x1={padL} x2={w - padR} y1={py(target)} y2={py(target)} stroke="var(--up)" strokeWidth="1" opacity="0.6" />
|
|
||||||
<text x={labelX} y={py(target) + 3.5} className="hz-lvl hz-lvl-up">target {fmt(target)}</text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<text x={labelX} y={isShort ? h - padB : padT + 4} className="hz-lvl hz-lvl-up">
|
|
||||||
target {fmt(target)} {isShort ? '↓' : '↑'}
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{contextPath && (
|
{contextPath && (
|
||||||
<path d={contextPath} fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" opacity="0.7" />
|
<path d={contextPath} fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" opacity="0.65" />
|
||||||
|
)}
|
||||||
|
{tradeSegs.map((s, i) => (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
d={s.d}
|
||||||
|
fill="none"
|
||||||
|
stroke={s.profit ? 'var(--up)' : 'var(--down)'}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Single-bar trade: no segment yet — mark entry→now with a short stem if needed */}
|
||||||
|
{entryIdx === lastIdx && (
|
||||||
|
<circle cx={px(entryIdx)} cy={py(series[entryIdx])} r="2" fill="var(--ink-3)" />
|
||||||
)}
|
)}
|
||||||
<path d={tradePath} fill="none" stroke={col} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
|
||||||
<circle cx={px(entryIdx)} cy={py(entry)} r="3.5" fill="var(--ink-2)" stroke="var(--surface)" strokeWidth="1.5" />
|
<circle cx={px(entryIdx)} cy={py(entry)} r="3.5" fill="var(--ink-2)" stroke="var(--surface)" strokeWidth="1.5" />
|
||||||
<circle cx={px(series.length - 1)} cy={py(last)} r="4" fill={col} stroke="var(--surface)" strokeWidth="2" />
|
<circle cx={px(lastIdx)} cy={py(series[lastIdx])} r="4" fill={nowCol} stroke="var(--surface)" strokeWidth="2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,9 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o
|
|||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
const opened = new Date(trade.opened_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
const opened = new Date(trade.opened_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
const chartHint = exitMode === 'target'
|
const chartHint = trailMoved || exitMode === 'atr_trailing' || exitMode === 'trailing'
|
||||||
? 'since entry · entry / stop / target'
|
? 'entry · now · stop · trail · gate'
|
||||||
: exitMode === 'time'
|
: 'entry · now · stop · gate';
|
||||||
? 'since entry · entry / stop'
|
|
||||||
: 'since entry · entry / initial stop / trail';
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 px-2 pb-4 pt-1">
|
<div className="flex flex-col gap-4 px-2 pb-4 pt-1">
|
||||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4">
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4">
|
||||||
@@ -144,6 +142,7 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o
|
|||||||
exitMode={exitMode}
|
exitMode={exitMode}
|
||||||
atrMultiplier={atrMultiplier}
|
atrMultiplier={atrMultiplier}
|
||||||
trailingPct={trailingPct}
|
trailingPct={trailingPct}
|
||||||
|
currentPrice={trade.current_price}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user