feat(dashboard): chart performance on one percent axis
The comparison plotted both books in dollars and SPY on a hidden rescaled axis, stretched so its biggest move matched the books' biggest move. SPY's height therefore said nothing about which was ahead, and the stat cells showed R next to SPY's percent, which do not compare. Everything is now percent of the shadow book's equity: - shadow: actual P&L over equity at window start (start equity plus P&L realized before a pinned performance_start_date) - your picks: re-sized at the shadow book's rule (risk_pct, 20% notional cap), so the gap between books is selection only - SPY: buy & hold on the same axis A strategy-vs-SPY verdict measures SPY from the shadow book's first trade, so manual trades opening earlier no longer charge the strategy for SPY's run-up before it existed. R stats stay underneath for book-vs-book reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c48eb20c5
commit
4876f9ffdd
4 files changed
+405
-87
No files matched your search
@@ -835,8 +835,10 @@ async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
|
|||||||
return build_equity_curve(list(trades), ticker_closes, benchmark_closes)
|
return build_equity_curve(list(trades), ticker_closes, benchmark_closes)
|
||||||
|
|
||||||
|
|
||||||
def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list[float]:
|
def _cumulative_per_share(
|
||||||
"""Cumulative realized + mark-to-market P&L of one book on each day."""
|
trades: list, ticker_closes: dict, days: list[date], weight
|
||||||
|
) -> list[float]:
|
||||||
|
"""Σ per-share P&L x ``weight(trade)`` on each day, realized + mark-to-market."""
|
||||||
sorted_dates = {tid: sorted(c) for tid, c in ticker_closes.items()}
|
sorted_dates = {tid: sorted(c) for tid, c in ticker_closes.items()}
|
||||||
out: list[float] = []
|
out: list[float] = []
|
||||||
for d in days:
|
for d in days:
|
||||||
@@ -862,19 +864,89 @@ def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list
|
|||||||
per_share = (
|
per_share = (
|
||||||
ref - t.entry_price if t.direction == "long" else t.entry_price - ref
|
ref - t.entry_price if t.direction == "long" else t.entry_price - ref
|
||||||
)
|
)
|
||||||
total += per_share * t.shares
|
total += per_share * weight(t)
|
||||||
out.append(round(total, 2))
|
out.append(total)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list[float]:
|
||||||
|
"""Cumulative realized + mark-to-market P&L of one book on each day."""
|
||||||
|
return [
|
||||||
|
round(v, 2)
|
||||||
|
for v in _cumulative_per_share(trades, ticker_closes, days, lambda t: t.shares)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def strategy_sized_fraction(trade, risk_pct: float, notional_cap: float) -> float:
|
||||||
|
"""Shares per unit of equity the shadow book's sizing rule would buy.
|
||||||
|
|
||||||
|
``risk_pct`` of equity at risk to the stop, capped at ``notional_cap`` of
|
||||||
|
equity in notional — the same rule as ``shadow_book_service.position_shares``
|
||||||
|
minus the cash limit. Re-sizing a book this way leaves only *selection* to
|
||||||
|
differ, and expresses its result as a percent of equity like SPY's.
|
||||||
|
"""
|
||||||
|
risk_per_share = abs(trade.entry_price - trade.stop_loss)
|
||||||
|
if risk_per_share <= 0 or trade.entry_price <= 0:
|
||||||
|
return 0.0
|
||||||
|
return min(risk_pct / 100.0 / risk_per_share, notional_cap / trade.entry_price)
|
||||||
|
|
||||||
|
|
||||||
|
def _cumulative_sized_pct(
|
||||||
|
trades: list,
|
||||||
|
ticker_closes: dict,
|
||||||
|
days: list[date],
|
||||||
|
risk_pct: float,
|
||||||
|
notional_cap: float,
|
||||||
|
) -> list[float]:
|
||||||
|
"""Cumulative return of one book, in % of equity, at the shadow book's sizing."""
|
||||||
|
series = _cumulative_per_share(
|
||||||
|
trades,
|
||||||
|
ticker_closes,
|
||||||
|
days,
|
||||||
|
lambda t: strategy_sized_fraction(t, risk_pct, notional_cap),
|
||||||
|
)
|
||||||
|
return [round(100.0 * v, 2) for v in series]
|
||||||
|
|
||||||
|
|
||||||
|
async def _shadow_equity_base(db: AsyncSession, start_equity: float, first: date) -> float:
|
||||||
|
"""Shadow equity when the window opens: start equity + P&L realized before it.
|
||||||
|
|
||||||
|
Trades closed before a pinned ``performance_start_date`` are outside the
|
||||||
|
window, but they moved the equity the book sizes from — so the window's
|
||||||
|
percent return is measured against that equity, not the original stake.
|
||||||
|
"""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(PaperTrade).where(
|
||||||
|
PaperTrade.book == SHADOW_BOOK,
|
||||||
|
PaperTrade.status == "closed",
|
||||||
|
PaperTrade.close_price.is_not(None),
|
||||||
|
func.date(PaperTrade.closed_at) < first,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
realized = 0.0
|
||||||
|
for t in rows.scalars():
|
||||||
|
per_share = (
|
||||||
|
t.close_price - t.entry_price
|
||||||
|
if t.direction == "long"
|
||||||
|
else t.entry_price - t.close_price
|
||||||
|
)
|
||||||
|
realized += per_share * t.shares
|
||||||
|
return start_equity + realized
|
||||||
|
|
||||||
|
|
||||||
async def performance_summary(db: AsyncSession, user_id: int | None = None) -> dict:
|
async def performance_summary(db: AsyncSession, user_id: int | None = None) -> dict:
|
||||||
"""Shadow book vs discretionary book vs SPY, from the configured start date.
|
"""Shadow book vs discretionary book vs SPY, from the configured start date.
|
||||||
|
|
||||||
Currency P&L is reported per book but is *not* the comparison — the books
|
Everything is charted in one unit — percent of the shadow book's equity —
|
||||||
size differently, so the honest read is the R-multiple stats. SPY is a plain
|
because SPY is a percent and R is not a return. The shadow line is its
|
||||||
buy-and-hold reference over the same window rather than a per-trade
|
actual P&L over its equity base; the manual book has no capital base, so it
|
||||||
counterfactual, so one line serves both books.
|
is re-sized at the shadow book's rule (``strategy_sized_fraction``), which
|
||||||
|
also makes the gap between the two books pure selection. SPY is a plain
|
||||||
|
buy-and-hold reference over the same window. R-multiple stats stay as the
|
||||||
|
sizing-independent book-vs-book read.
|
||||||
"""
|
"""
|
||||||
|
from app.services import shadow_book_service
|
||||||
|
|
||||||
start = await get_performance_start(db)
|
start = await get_performance_start(db)
|
||||||
stmt = select(PaperTrade)
|
stmt = select(PaperTrade)
|
||||||
if start is not None:
|
if start is not None:
|
||||||
@@ -929,6 +1001,19 @@ async def performance_summary(db: AsyncSession, user_id: int | None = None) -> d
|
|||||||
for d in days
|
for d in days
|
||||||
]
|
]
|
||||||
|
|
||||||
|
config = await shadow_book_service.get_config(db)
|
||||||
|
equity_base = await _shadow_equity_base(db, config["start_equity"], days[0])
|
||||||
|
pct = {
|
||||||
|
SHADOW_BOOK: [round(100.0 * v / equity_base, 2) for v in pnl[SHADOW_BOOK]],
|
||||||
|
MANUAL_BOOK: _cumulative_sized_pct(
|
||||||
|
books[MANUAL_BOOK],
|
||||||
|
ticker_closes,
|
||||||
|
days,
|
||||||
|
config["risk_pct"],
|
||||||
|
shadow_book_service.NOTIONAL_CAP,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# Latest close per ticker, for marking open positions in the R stats.
|
# Latest close per ticker, for marking open positions in the R stats.
|
||||||
marks = {
|
marks = {
|
||||||
tid: closes[max(closes)] for tid, closes in ticker_closes.items() if closes
|
tid: closes[max(closes)] for tid, closes in ticker_closes.items() if closes
|
||||||
@@ -936,15 +1021,34 @@ async def performance_summary(db: AsyncSession, user_id: int | None = None) -> d
|
|||||||
stats = {name: book_stats(bt, marks) for name, bt in books.items()}
|
stats = {name: book_stats(bt, marks) for name, bt in books.items()}
|
||||||
for name in books:
|
for name in books:
|
||||||
stats[name]["pnl"] = pnl[name][-1] if pnl[name] else 0.0
|
stats[name]["pnl"] = pnl[name][-1] if pnl[name] else 0.0
|
||||||
|
stats[name]["pct"] = pct[name][-1] if pct[name] else 0.0
|
||||||
stats["spy"] = {"pct": spy_pct[-1] if spy_pct else 0.0}
|
stats["spy"] = {"pct": spy_pct[-1] if spy_pct else 0.0}
|
||||||
|
|
||||||
|
# The series opens at the first trade in *either* book. If manual trades came
|
||||||
|
# first, SPY's full-window return includes days the strategy did not exist,
|
||||||
|
# so strategy-vs-SPY is judged from the shadow book's own first trade.
|
||||||
|
if books[SHADOW_BOOK]:
|
||||||
|
since = max(min(t.opened_at.date() for t in books[SHADOW_BOOK]), days[0])
|
||||||
|
s0 = _value_on_or_before(bench_dates, benchmark_closes, since)
|
||||||
|
s1 = benchmark_closes[days[-1]]
|
||||||
|
stats[SHADOW_BOOK]["since"] = since.isoformat()
|
||||||
|
stats[SHADOW_BOOK]["spy_pct"] = round(100.0 * (s1 / s0 - 1.0), 2) if s0 else 0.0
|
||||||
|
|
||||||
series = [
|
series = [
|
||||||
{
|
{
|
||||||
"date": d.isoformat(),
|
"date": d.isoformat(),
|
||||||
"manual_pnl": pnl[MANUAL_BOOK][i],
|
"manual_pnl": pnl[MANUAL_BOOK][i],
|
||||||
"shadow_pnl": pnl[SHADOW_BOOK][i],
|
"shadow_pnl": pnl[SHADOW_BOOK][i],
|
||||||
|
"manual_pct": pct[MANUAL_BOOK][i],
|
||||||
|
"shadow_pct": pct[SHADOW_BOOK][i],
|
||||||
"spy_pct": spy_pct[i],
|
"spy_pct": spy_pct[i],
|
||||||
}
|
}
|
||||||
for i, d in enumerate(days)
|
for i, d in enumerate(days)
|
||||||
]
|
]
|
||||||
return {"start_date": start.isoformat() if start else None, "series": series, "stats": stats}
|
return {
|
||||||
|
"start_date": start.isoformat() if start else None,
|
||||||
|
"equity_base": round(equity_base, 2),
|
||||||
|
"risk_pct": config["risk_pct"],
|
||||||
|
"series": series,
|
||||||
|
"stats": stats,
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ export interface PerfPoint {
|
|||||||
date: string;
|
date: string;
|
||||||
manual_pnl: number;
|
manual_pnl: number;
|
||||||
shadow_pnl: number;
|
shadow_pnl: number;
|
||||||
|
/** Percent of the shadow book's equity base — its actual result. */
|
||||||
|
shadow_pct: number;
|
||||||
|
/** Percent of equity as if sized by the shadow book's rule (hand sizing removed). */
|
||||||
|
manual_pct: number;
|
||||||
spy_pct: number;
|
spy_pct: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +53,18 @@ export interface BookStats {
|
|||||||
total_r: number;
|
total_r: number;
|
||||||
avg_r: number | null;
|
avg_r: number | null;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
|
pct: number;
|
||||||
|
/** Shadow only: the book's first trade, and SPY's return from then — the fair yardstick. */
|
||||||
|
since?: string;
|
||||||
|
spy_pct?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PerformanceSummary {
|
export interface PerformanceSummary {
|
||||||
start_date: string | null;
|
start_date: string | null;
|
||||||
|
/** Shadow equity when the window opens — the base every % line is measured on. */
|
||||||
|
equity_base?: number;
|
||||||
|
/** Shadow book risk per trade, % of equity — so 1R ≈ this many percent. */
|
||||||
|
risk_pct?: number;
|
||||||
series: PerfPoint[];
|
series: PerfPoint[];
|
||||||
stats: {
|
stats: {
|
||||||
manual?: BookStats;
|
manual?: BookStats;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPerformance, type BookStats } from '../../api/paperTrades';
|
import { getPerformance, type BookStats, type PerfPoint } from '../../api/paperTrades';
|
||||||
import { Section } from '../ui/Section';
|
import { Section } from '../ui/Section';
|
||||||
|
|
||||||
const W = 1040;
|
const W = 1040;
|
||||||
const H = 260;
|
const H = 260;
|
||||||
const PAD = { top: 16, right: 92, bottom: 28, left: 60 };
|
const PAD = { top: 16, right: 120, bottom: 28, left: 60 };
|
||||||
|
const LABEL_GAP = 14;
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
shadow: 'var(--up)',
|
shadow: 'var(--up)',
|
||||||
@@ -13,72 +14,102 @@ const COLORS = {
|
|||||||
spy: 'var(--ink-3)',
|
spy: 'var(--ink-3)',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
type SeriesKey = 'shadow_pct' | 'manual_pct' | 'spy_pct';
|
||||||
|
|
||||||
function money(v: number): string {
|
function money(v: number): string {
|
||||||
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
|
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
|
||||||
const abs = Math.abs(v);
|
const abs = Math.abs(v);
|
||||||
return `${sign}$${abs >= 10000 ? `${(abs / 1000).toFixed(1)}k` : abs.toFixed(0)}`;
|
return `${sign}$${abs >= 10000 ? `${(abs / 1000).toFixed(1)}k` : abs.toFixed(0)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pct(v: number, digits = 1): string {
|
||||||
|
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
|
||||||
|
return `${sign}${Math.abs(v).toFixed(digits)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rMult(v: number): string {
|
||||||
|
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Round tick steps to a clean 1/2/5 x 10^n ladder. */
|
/** Round tick steps to a clean 1/2/5 x 10^n ladder. */
|
||||||
function niceTicks(lo: number, hi: number, count = 4): number[] {
|
function niceTicks(lo: number, hi: number, count = 4): { ticks: number[]; step: number } {
|
||||||
const span = hi - lo || 1;
|
const span = hi - lo || 1;
|
||||||
const raw = span / count;
|
const raw = span / count;
|
||||||
const mag = 10 ** Math.floor(Math.log10(raw));
|
const mag = 10 ** Math.floor(Math.log10(raw));
|
||||||
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
|
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
|
||||||
const start = Math.ceil(lo / step) * step;
|
const ticks: number[] = [];
|
||||||
const out: number[] = [];
|
// Integer multiples of the step, so float drift never prints "+0.30000001%".
|
||||||
for (let v = start; v <= hi; v += step) out.push(v);
|
for (let k = Math.ceil(lo / step); k * step <= hi; k++) ticks.push(k * step);
|
||||||
return out;
|
return { ticks, step };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** R-multiple stats read straight across; currency P&L does not, because the
|
/** Headline in % (the chart's unit, comparable to SPY); R and $ underneath. */
|
||||||
* books size differently. Kept adjacent so the comparison is hard to misread. */
|
function StatCell({
|
||||||
function StatCell({ label, stats, color }: { label: string; stats?: BookStats; color: string }) {
|
label,
|
||||||
|
note,
|
||||||
|
stats,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
note: string;
|
||||||
|
stats?: BookStats;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
const head = (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||||
|
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
if (!stats || stats.trades === 0) {
|
if (!stats || stats.trades === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[7rem]">
|
<div className="min-w-[7rem]">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
{head}
|
||||||
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div className="num mt-1 text-sm text-gray-500">no trades yet</div>
|
<div className="num mt-1 text-sm text-gray-500">no trades yet</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[7rem]">
|
<div className="min-w-[7rem]">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
{head}
|
||||||
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
|
<div className="num mt-1 text-lg font-semibold text-gray-100">{pct(stats.pct)}</div>
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div className="num mt-1 text-lg font-semibold text-gray-100">
|
|
||||||
{stats.total_r > 0 ? '+' : ''}
|
|
||||||
{stats.total_r.toFixed(2)}R
|
|
||||||
</div>
|
|
||||||
<div className="num text-[11px] leading-relaxed text-gray-500">
|
<div className="num text-[11px] leading-relaxed text-gray-500">
|
||||||
{stats.trades} trades · {stats.win_rate ?? '—'}% win
|
{note}
|
||||||
<br />
|
<br />
|
||||||
avg {stats.avg_r === null ? '—' : `${stats.avg_r > 0 ? '+' : ''}${stats.avg_r.toFixed(2)}R`} · {money(stats.pnl)}
|
{rMult(stats.total_r)} · {stats.trades} trades · {stats.win_rate ?? '—'}% win
|
||||||
|
<br />
|
||||||
|
avg {stats.avg_r === null ? '—' : rMult(stats.avg_r)} · {money(stats.pnl)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shadow book (the strategy, traded automatically) vs the discretionary book
|
/** Shadow book (the strategy, traded automatically) vs the discretionary book
|
||||||
* vs SPY. Both books share an exit policy, so the only difference is which
|
* vs SPY, all in percent of the shadow book's equity. R is not a return and
|
||||||
* qualified setups get taken. */
|
* cannot sit on SPY's axis; percent of equity is the one unit all three share. */
|
||||||
export function PerfChart() {
|
export function PerfChart() {
|
||||||
const perf = useQuery({ queryKey: ['paper-trades', 'performance'], queryFn: getPerformance });
|
const perf = useQuery({ queryKey: ['paper-trades', 'performance'], queryFn: getPerformance });
|
||||||
const [hover, setHover] = useState<number | null>(null);
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
const data = perf.data?.series ?? [];
|
const data = useMemo(() => perf.data?.series ?? [], [perf.data]);
|
||||||
const stats = perf.data?.stats ?? {};
|
const stats = perf.data?.stats ?? {};
|
||||||
const startDate = perf.data?.start_date ?? null;
|
const startDate = perf.data?.start_date ?? null;
|
||||||
|
const equityBase = perf.data?.equity_base ?? null;
|
||||||
|
const riskPct = perf.data?.risk_pct ?? null;
|
||||||
|
|
||||||
|
// A book with no trades would draw a flat line on zero that reads as "0%".
|
||||||
|
const lines = useMemo(() => {
|
||||||
|
const out: { key: SeriesKey; color: string; label: string; dashed?: boolean }[] = [];
|
||||||
|
out.push({ key: 'spy_pct', color: COLORS.spy, label: 'SPY', dashed: true });
|
||||||
|
if ((stats.manual?.trades ?? 0) > 0) out.push({ key: 'manual_pct', color: COLORS.manual, label: 'yours' });
|
||||||
|
if ((stats.shadow?.trades ?? 0) > 0) out.push({ key: 'shadow_pct', color: COLORS.shadow, label: 'strategy' });
|
||||||
|
return out;
|
||||||
|
}, [stats.manual?.trades, stats.shadow?.trades]);
|
||||||
|
|
||||||
const geom = useMemo(() => {
|
const geom = useMemo(() => {
|
||||||
if (data.length < 2) return null;
|
if (data.length < 2) return null;
|
||||||
const values = data.flatMap((p) => [p.manual_pnl, p.shadow_pnl, 0]);
|
const values = data.flatMap((p) => [...lines.map((l) => p[l.key]), 0]);
|
||||||
const lo = Math.min(...values);
|
const lo = Math.min(...values);
|
||||||
const hi = Math.max(...values);
|
const hi = Math.max(...values);
|
||||||
const pad = (hi - lo) * 0.08 || 1;
|
const pad = (hi - lo) * 0.08 || 1;
|
||||||
@@ -88,19 +119,19 @@ export function PerfChart() {
|
|||||||
const plotH = H - PAD.top - PAD.bottom;
|
const plotH = H - PAD.top - PAD.bottom;
|
||||||
const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW;
|
const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW;
|
||||||
const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH;
|
const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH;
|
||||||
const line = (key: 'manual_pnl' | 'shadow_pnl') =>
|
const path = (key: SeriesKey) =>
|
||||||
data.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' ');
|
data.map((p: PerfPoint, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' ');
|
||||||
|
|
||||||
// SPY is a percentage reference, so it rides its own scale pinned to the
|
// End-of-line labels, nudged apart so lines finishing close together stay legible.
|
||||||
// same zero line — otherwise a flat book would squash it out of view.
|
const last = data[data.length - 1];
|
||||||
const spyLo = Math.min(...data.map((p) => p.spy_pct), 0);
|
const ends = lines
|
||||||
const spyHi = Math.max(...data.map((p) => p.spy_pct), 0);
|
.map((l) => ({ ...l, value: last[l.key], y: py(last[l.key]) + 4 }))
|
||||||
const spySpan = Math.max(Math.abs(spyLo), Math.abs(spyHi)) || 1;
|
.sort((a, b) => a.y - b.y);
|
||||||
const bookSpan = Math.max(Math.abs(yLo), Math.abs(yHi)) || 1;
|
for (let k = 1; k < ends.length; k++) {
|
||||||
const spyY = (v: number) => py((v / spySpan) * bookSpan);
|
ends[k].y = Math.max(ends[k].y, ends[k - 1].y + LABEL_GAP);
|
||||||
const spyLine = data
|
}
|
||||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${spyY(p.spy_pct).toFixed(1)}`)
|
const overflow = ends.length ? ends[ends.length - 1].y - (PAD.top + plotH + 4) : 0;
|
||||||
.join(' ');
|
if (overflow > 0) ends.forEach((e) => (e.y -= overflow));
|
||||||
|
|
||||||
const xTicks: { i: number; label: string }[] = [];
|
const xTicks: { i: number; label: string }[] = [];
|
||||||
let lastMonth = '';
|
let lastMonth = '';
|
||||||
@@ -115,12 +146,12 @@ export function PerfChart() {
|
|||||||
const keep = Math.ceil(xTicks.length / 10);
|
const keep = Math.ceil(xTicks.length / 10);
|
||||||
for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1);
|
for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1);
|
||||||
}
|
}
|
||||||
return { yLo, yHi, plotH, px, py, line, spyLine, spyY, yTicks: niceTicks(yLo, yHi), xTicks };
|
return { plotH, px, py, path, ends, yTicks: niceTicks(yLo, yHi), xTicks };
|
||||||
}, [data]);
|
}, [data, lines]);
|
||||||
|
|
||||||
if (!geom) {
|
if (!geom) {
|
||||||
return (
|
return (
|
||||||
<Section title="Performance" hint="shadow book vs your picks vs SPY">
|
<Section title="Performance" hint="strategy vs your picks vs SPY">
|
||||||
<div className="glass p-5 text-sm text-gray-500">
|
<div className="glass p-5 text-sm text-gray-500">
|
||||||
No trades in the selected window yet
|
No trades in the selected window yet
|
||||||
{startDate ? ` (since ${startDate})` : ''}. The shadow book starts recording once enabled.
|
{startDate ? ` (since ${startDate})` : ''}. The shadow book starts recording once enabled.
|
||||||
@@ -128,7 +159,7 @@ export function PerfChart() {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { px, py, line, spyLine, spyY, yTicks, xTicks, plotH } = geom;
|
const { px, py, path, ends, yTicks, xTicks, plotH } = geom;
|
||||||
|
|
||||||
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
const rect = svgRef.current?.getBoundingClientRect();
|
const rect = svgRef.current?.getBoundingClientRect();
|
||||||
@@ -138,38 +169,61 @@ export function PerfChart() {
|
|||||||
setHover(Math.max(0, Math.min(data.length - 1, i)));
|
setHover(Math.max(0, Math.min(data.length - 1, i)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const last = data.length - 1;
|
const last = data[data.length - 1];
|
||||||
const hb = hover !== null ? data[hover] : null;
|
const hb = hover !== null ? data[hover] : null;
|
||||||
const fmtDate = (iso: string) =>
|
const fmtDate = (iso: string) =>
|
||||||
new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
|
// The question the card exists to answer: did the strategy beat sitting in SPY?
|
||||||
|
// SPY is measured from the strategy's first trade: if your picks started
|
||||||
|
// earlier, the full-window SPY figure would include days it didn't exist.
|
||||||
|
const hasShadow = (stats.shadow?.trades ?? 0) > 0;
|
||||||
|
const shadowPct = stats.shadow?.pct ?? 0;
|
||||||
|
const spyRef = stats.shadow?.spy_pct ?? stats.spy?.pct ?? 0;
|
||||||
|
const edge = hasShadow ? shadowPct - spyRef : null;
|
||||||
|
const since = stats.shadow?.since && stats.shadow.since !== data[0].date ? stats.shadow.since : null;
|
||||||
|
const base = equityBase !== null ? `$${Math.round(equityBase / 1000)}k` : 'the book';
|
||||||
|
const risk = riskPct !== null ? `${riskPct}%` : '1%';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
title="Performance"
|
title="Performance"
|
||||||
hint={`shadow book vs your picks vs SPY${startDate ? ` · since ${startDate}` : ''}`}
|
hint={`strategy vs your picks vs SPY, % of equity${startDate ? ` · since ${startDate}` : ''}`}
|
||||||
>
|
>
|
||||||
<div className="glass p-5 pb-2">
|
<div className="glass p-5 pb-2">
|
||||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-8 gap-y-3">
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-8 gap-y-3">
|
||||||
<div className="flex flex-wrap gap-x-8 gap-y-3">
|
<div className="flex flex-wrap gap-x-8 gap-y-3">
|
||||||
<StatCell label="Shadow (strategy)" stats={stats.shadow} color={COLORS.shadow} />
|
<StatCell label="Shadow (strategy)" note={`actual, on ${base}`} stats={stats.shadow} color={COLORS.shadow} />
|
||||||
<StatCell label="Your picks" stats={stats.manual} color={COLORS.manual} />
|
<StatCell label="Your picks" note="at strategy sizing" stats={stats.manual} color={COLORS.manual} />
|
||||||
<div className="min-w-[7rem]">
|
<div className="min-w-[7rem]">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||||
<i className="inline-block h-2 w-2 rounded-full" style={{ background: COLORS.spy }} />
|
<i className="inline-block h-2 w-2 rounded-full" style={{ background: COLORS.spy }} />
|
||||||
SPY
|
SPY
|
||||||
</div>
|
</div>
|
||||||
<div className="num mt-1 text-lg font-semibold text-gray-300">
|
<div className="num mt-1 text-lg font-semibold text-gray-300">{pct(stats.spy?.pct ?? 0)}</div>
|
||||||
{(stats.spy?.pct ?? 0) > 0 ? '+' : ''}
|
<div className="num text-[11px] text-gray-500">buy & hold, same window</div>
|
||||||
{(stats.spy?.pct ?? 0).toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<div className="num text-[11px] text-gray-500">buy & hold</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-[22rem] text-[11px] leading-relaxed text-gray-500">
|
<div className="max-w-[24rem] text-[11px] leading-relaxed text-gray-500">
|
||||||
Books share the same exit, so the difference is <b className="text-gray-400">selection</b>.
|
{edge !== null && (
|
||||||
Compare on <b className="text-gray-400">R</b>, not $ — sizing differs. Expect months of
|
<p className="num mb-1.5 text-xs text-gray-400">
|
||||||
noise before a gap means anything.
|
Strategy vs SPY{since ? ` since ${fmtDate(since)}` : ''}:{' '}
|
||||||
</p>
|
<b style={{ color: edge >= 0 ? 'var(--up-text)' : 'var(--down-text)' }}>
|
||||||
|
{edge >= 0 ? '+' : '−'}
|
||||||
|
{Math.abs(edge).toFixed(1)} pts {edge >= 0 ? 'ahead' : 'behind'}
|
||||||
|
</b>{' '}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
({pct(shadowPct)} vs {pct(spyRef)})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
Every line is <b className="text-gray-400">% of the strategy's {base} equity</b>, the
|
||||||
|
same unit as SPY. Your picks are re-sized to its {risk} risk per trade, so the gap between
|
||||||
|
the books is <b className="text-gray-400">selection</b>. 1R ≈ {risk} of equity (less when a
|
||||||
|
position hits the size cap). Expect months of noise before a gap means anything.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
@@ -178,15 +232,15 @@ export function PerfChart() {
|
|||||||
onMouseMove={onMove}
|
onMouseMove={onMove}
|
||||||
onMouseLeave={() => setHover(null)}
|
onMouseLeave={() => setHover(null)}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Shadow book ${money(data[last].shadow_pnl)}, your picks ${money(
|
aria-label={`Strategy ${pct(last.shadow_pct)}, your picks ${pct(last.manual_pct)}, SPY ${pct(
|
||||||
data[last].manual_pnl,
|
last.spy_pct,
|
||||||
)}, SPY ${(data[last].spy_pct ?? 0).toFixed(1)} percent`}
|
)}, as percent of equity`}
|
||||||
>
|
>
|
||||||
{yTicks.map((t) => (
|
{yTicks.ticks.map((t) => (
|
||||||
<g key={t}>
|
<g key={t}>
|
||||||
<line x1={PAD.left} x2={W - PAD.right} y1={py(t)} y2={py(t)} stroke="var(--grid)" strokeWidth="1" />
|
<line x1={PAD.left} x2={W - PAD.right} y1={py(t)} y2={py(t)} stroke="var(--grid)" strokeWidth="1" />
|
||||||
<text x={PAD.left - 8} y={py(t) + 3.5} textAnchor="end" className="num" fill="var(--ink-3)" fontSize="10">
|
<text x={PAD.left - 8} y={py(t) + 3.5} textAnchor="end" className="num" fill="var(--ink-3)" fontSize="10">
|
||||||
{money(t)}
|
{pct(t, yTicks.step < 1 ? 1 : 0)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
@@ -196,30 +250,53 @@ export function PerfChart() {
|
|||||||
{label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
<path d={spyLine} fill="none" stroke={COLORS.spy} strokeWidth="1.5" strokeDasharray="4 3" strokeLinejoin="round" />
|
{lines.map((l) => (
|
||||||
<path d={line('manual_pnl')} fill="none" stroke={COLORS.manual} strokeWidth="2" strokeLinejoin="round" />
|
<path
|
||||||
<path d={line('shadow_pnl')} fill="none" stroke={COLORS.shadow} strokeWidth="2" strokeLinejoin="round" />
|
key={l.key}
|
||||||
|
d={path(l.key)}
|
||||||
|
fill="none"
|
||||||
|
stroke={l.color}
|
||||||
|
strokeWidth={l.dashed ? 1.5 : 2}
|
||||||
|
strokeDasharray={l.dashed ? '4 3' : undefined}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{hover !== null && (
|
{hover !== null && (
|
||||||
<g>
|
<g>
|
||||||
<line x1={px(hover)} x2={px(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--ink-3)" strokeWidth="1" />
|
<line x1={px(hover)} x2={px(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--ink-3)" strokeWidth="1" />
|
||||||
<circle cx={px(hover)} cy={py(data[hover].shadow_pnl)} r="4.5" fill={COLORS.shadow} stroke="var(--surface)" strokeWidth="2" />
|
{lines.map((l) => (
|
||||||
<circle cx={px(hover)} cy={py(data[hover].manual_pnl)} r="4.5" fill={COLORS.manual} stroke="var(--surface)" strokeWidth="2" />
|
<circle
|
||||||
<circle cx={px(hover)} cy={spyY(data[hover].spy_pct)} r="4" fill={COLORS.spy} stroke="var(--surface)" strokeWidth="2" />
|
key={l.key}
|
||||||
|
cx={px(hover)}
|
||||||
|
cy={py(data[hover][l.key])}
|
||||||
|
r={l.dashed ? 4 : 4.5}
|
||||||
|
fill={l.color}
|
||||||
|
stroke="var(--surface)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
<text x={px(last) + 10} y={py(data[last].shadow_pnl) + 4} className="num" fill="var(--ink)" fontSize="11" fontWeight="600">
|
{ends.map((e) => (
|
||||||
{money(data[last].shadow_pnl)}
|
<text
|
||||||
</text>
|
key={e.key}
|
||||||
<text x={px(last) + 10} y={py(data[last].manual_pnl) + 4} className="num" fill="var(--ink-2)" fontSize="11">
|
x={px(data.length - 1) + 10}
|
||||||
{money(data[last].manual_pnl)}
|
y={e.y}
|
||||||
</text>
|
className="num"
|
||||||
|
fill={e.key === 'shadow_pct' ? 'var(--ink)' : e.key === 'manual_pct' ? 'var(--ink-2)' : 'var(--ink-3)'}
|
||||||
|
fontSize="11"
|
||||||
|
fontWeight={e.key === 'shadow_pct' ? 600 : undefined}
|
||||||
|
>
|
||||||
|
{pct(e.value)} {e.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
<p className="num px-1 pb-1 pt-1.5 text-[11px] text-gray-500" aria-live="polite">
|
<p className="num px-1 pb-1 pt-1.5 text-[11px] text-gray-500" aria-live="polite">
|
||||||
{hb ? (
|
{hb ? (
|
||||||
<>
|
<>
|
||||||
{fmtDate(hb.date)} — shadow <b className="text-gray-200">{money(hb.shadow_pnl)}</b> · yours{' '}
|
{fmtDate(hb.date)} — strategy <b className="text-gray-200">{pct(hb.shadow_pct)}</b> · yours{' '}
|
||||||
<b className="text-gray-200">{money(hb.manual_pnl)}</b> · SPY{' '}
|
<b className="text-gray-200">{pct(hb.manual_pct)}</b> · SPY{' '}
|
||||||
<b className="text-gray-200">{hb.spy_pct.toFixed(1)}%</b>
|
<b className="text-gray-200">{pct(hb.spy_pct)}</b>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>hover for daily values · realized + mark-to-market{startDate ? ` · window starts ${startDate}` : ''}</>
|
<>hover for daily values · realized + mark-to-market{startDate ? ` · window starts ${startDate}` : ''}</>
|
||||||
|
|||||||
@@ -68,6 +68,131 @@ class TestBookStats:
|
|||||||
assert stats["total_r"] == pytest.approx(4.0)
|
assert stats["total_r"] == pytest.approx(4.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrategySizedPercent:
|
||||||
|
"""Both books and SPY share one unit — percent of equity — on the chart."""
|
||||||
|
|
||||||
|
def test_one_r_is_risk_pct_of_equity(self):
|
||||||
|
# Entry 100, stop 90 → 10 of risk; 1% risk → 0.001 shares per $ equity
|
||||||
|
# (10% notional, under the 20% cap). Exit 120 = +2R = +2% of equity.
|
||||||
|
trade = _trade(book=MANUAL_BOOK, entry=100.0, stop=90.0, close=120.0)
|
||||||
|
assert pts.strategy_sized_fraction(trade, 1.0, 0.20) == pytest.approx(0.001)
|
||||||
|
days = [trade.closed_at.date()]
|
||||||
|
assert pts._cumulative_sized_pct([trade], {}, days, 1.0, 0.20) == [2.0]
|
||||||
|
|
||||||
|
def test_notional_cap_shrinks_tight_stop_trades(self):
|
||||||
|
# Stop 1 away would need 100% of equity in notional for 1% risk; the
|
||||||
|
# 20% cap leaves 0.2% at risk, so +1R is only +0.2% of equity.
|
||||||
|
trade = _trade(book=MANUAL_BOOK, entry=100.0, stop=99.0, close=101.0)
|
||||||
|
days = [trade.closed_at.date()]
|
||||||
|
assert pts._cumulative_sized_pct([trade], {}, days, 1.0, 0.20) == [0.2]
|
||||||
|
|
||||||
|
def test_hand_sized_shares_do_not_matter(self):
|
||||||
|
small = _trade(book=MANUAL_BOOK, entry=100.0, stop=90.0, close=120.0, shares=1)
|
||||||
|
large = _trade(book=MANUAL_BOOK, entry=100.0, stop=90.0, close=120.0, shares=5000)
|
||||||
|
days = [small.closed_at.date()]
|
||||||
|
assert pts._cumulative_sized_pct(
|
||||||
|
[small], {}, days, 1.0, 0.20
|
||||||
|
) == pts._cumulative_sized_pct([large], {}, days, 1.0, 0.20)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformanceSummaryPercent:
|
||||||
|
@pytest.fixture
|
||||||
|
async def session(self):
|
||||||
|
from tests.conftest import _test_session_factory
|
||||||
|
|
||||||
|
async with _test_session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shadow_percent_is_measured_on_equity_at_window_start(self, session):
|
||||||
|
"""A pinned start date moves the base: P&L realized before it is equity."""
|
||||||
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
|
from app.models.ohlcv import OHLCVRecord
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
def at(d):
|
||||||
|
return datetime(d.year, d.month, d.day, 20, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||||
|
ticker = Ticker(symbol="AAA")
|
||||||
|
session.add(ticker)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
OHLCVRecord(ticker_id=ticker.id, date=today, open=110, high=110, low=110,
|
||||||
|
close=110, volume=1)
|
||||||
|
)
|
||||||
|
for offset, close in ((10, 390.0), (3, 400.0), (0, 412.0)):
|
||||||
|
session.add(BenchmarkPrice(symbol="SPY", date=today - timedelta(days=offset),
|
||||||
|
close=close))
|
||||||
|
common = dict(user_id=1, ticker_id=ticker.id, direction="long", entry_price=100.0,
|
||||||
|
stop_loss=90.0, target=150.0, fill_mode="near_close")
|
||||||
|
# Before the window: +$5,000 realized, so the window's base is $105k.
|
||||||
|
session.add(PaperTrade(**common, book=SHADOW_BOOK, shares=100, status="closed",
|
||||||
|
close_price=150.0, opened_at=at(today - timedelta(days=10)),
|
||||||
|
closed_at=at(today - timedelta(days=8))))
|
||||||
|
# In the window: +$1,050 on 105 shares = +1.0% of $105k.
|
||||||
|
session.add(PaperTrade(**common, book=SHADOW_BOOK, shares=105, status="open",
|
||||||
|
opened_at=at(today - timedelta(days=3))))
|
||||||
|
# Hand-sized manual trade, +1R → +1.0% at strategy sizing whatever its shares.
|
||||||
|
session.add(PaperTrade(**common, book=MANUAL_BOOK, shares=7, status="open",
|
||||||
|
opened_at=at(today - timedelta(days=3))))
|
||||||
|
await session.commit()
|
||||||
|
await pts.settings_store.upsert_setting(
|
||||||
|
session, pts.KEY_PERFORMANCE_START, (today - timedelta(days=5)).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = await pts.performance_summary(session, user_id=1)
|
||||||
|
|
||||||
|
assert summary["equity_base"] == pytest.approx(105_000.0)
|
||||||
|
assert summary["risk_pct"] == pytest.approx(1.0)
|
||||||
|
last = summary["series"][-1]
|
||||||
|
assert last["shadow_pnl"] == pytest.approx(1050.0)
|
||||||
|
assert last["shadow_pct"] == pytest.approx(1.0)
|
||||||
|
assert last["manual_pct"] == pytest.approx(1.0)
|
||||||
|
assert last["spy_pct"] == pytest.approx(3.0)
|
||||||
|
assert summary["stats"]["shadow"]["pct"] == pytest.approx(1.0)
|
||||||
|
assert summary["stats"]["manual"]["pct"] == pytest.approx(1.0)
|
||||||
|
assert summary["stats"]["shadow"]["spy_pct"] == pytest.approx(3.0)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_strategy_vs_spy_starts_at_the_first_shadow_trade(self, session):
|
||||||
|
"""Manual trades opening first must not charge the strategy for SPY's run-up."""
|
||||||
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
def at(d):
|
||||||
|
return datetime(d.year, d.month, d.day, 20, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||||
|
ticker = Ticker(symbol="AAA")
|
||||||
|
session.add(ticker)
|
||||||
|
await session.flush()
|
||||||
|
for offset, close in ((6, 400.0), (2, 420.0), (0, 441.0)):
|
||||||
|
session.add(BenchmarkPrice(symbol="SPY", date=today - timedelta(days=offset),
|
||||||
|
close=close))
|
||||||
|
common = dict(user_id=1, ticker_id=ticker.id, direction="long", entry_price=100.0,
|
||||||
|
stop_loss=90.0, target=150.0, fill_mode="near_close", shares=10,
|
||||||
|
status="open")
|
||||||
|
session.add(PaperTrade(**common, book=MANUAL_BOOK, opened_at=at(today - timedelta(days=6))))
|
||||||
|
session.add(PaperTrade(**common, book=SHADOW_BOOK, opened_at=at(today - timedelta(days=2))))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
summary = await pts.performance_summary(session, user_id=1)
|
||||||
|
|
||||||
|
# Full window 400 → 441 = +10.25%; the strategy's own window 420 → 441 = +5%.
|
||||||
|
assert summary["stats"]["spy"]["pct"] == pytest.approx(10.25)
|
||||||
|
assert summary["stats"]["shadow"]["spy_pct"] == pytest.approx(5.0)
|
||||||
|
assert summary["stats"]["shadow"]["since"] == (today - timedelta(days=2)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
class TestPerformanceStartDate:
|
class TestPerformanceStartDate:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def session(self):
|
async def session(self):
|
||||||
|
|||||||
Reference in new issue
Block a user