Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SignalsPage } from '../routes/SignalsPage';
import { PositionsPage } from '../routes/PositionsPage';
import { AnalyticsPage } from '../routes/AnalyticsPage';
import { SettingsPage } from '../routes/SettingsPage';
import { MethodologyPage } from '../routes/MethodologyPage';

function AuthGate() {
const { user, isLoading } = useAuth();
Expand All @@ -27,6 +28,7 @@ function AuthGate() {
<Route path="/positions" component={PositionsPage} />
<Route path="/analytics" component={AnalyticsPage} />
<Route path="/settings" component={SettingsPage} />
<Route path="/methodology" component={MethodologyPage} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make methodology reachable before authentication

When a signed-out user clicks either disclosure link on ConnectScreen, the URL changes to /methodology, but AuthGate returns ConnectScreen before reaching this authenticated Switch, so MethodologyPage never renders. This breaks the only pre-login “Learn more” path; handle the methodology route outside the authenticated branch or allow this path through the gate.

Useful? React with 👍 / 👎.

<Route>
<p className="text-ink-secondary">Page not found.</p>
</Route>
Expand Down
4 changes: 3 additions & 1 deletion client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ConnectionStatus } from '../features/realtime/ConnectionStatus';
import { ErrorBoundary } from './ErrorBoundary';
import { MobileNavDrawer } from './MobileNavDrawer';
import { NAV_ITEMS } from '../routes/nav';
import { Disclosure } from '../components/Disclosure';

function truncateAddress(address: string): string {
return `${address.slice(0, 6)}…${address.slice(-4)}`;
Expand Down Expand Up @@ -52,10 +53,11 @@ function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
})}
</nav>

<div className="border-t border-border-subtle p-4">
<div className="flex flex-col gap-3 border-t border-border-subtle p-4">
<Badge variant="paper" className="w-full justify-center">
Paper Trading
</Badge>
<Disclosure variant="compact" context="primary" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the disclosure visible on mobile pages

On viewports below the lg breakpoint, the desktop <aside> is hidden and this SidebarContent instance exists only inside the closed Radix navigation dialog in MobileNavDrawer.tsx. Consequently authenticated mobile users see no persistent disclosure unless they open the menu, contrary to the intended every-page reminder; render a compact disclosure outside the drawer for these viewports.

Useful? React with 👍 / 👎.

</div>
</>
);
Expand Down
3 changes: 3 additions & 0 deletions client/src/app/ConnectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isPhantomInstalledWithoutEvmProvider } from '../features/auth/eip6963';
import { WalletList } from '../features/auth/WalletList';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Disclosure } from '../components/Disclosure';

export function ConnectScreen() {
const {
Expand Down Expand Up @@ -47,6 +48,8 @@ export function ConnectScreen() {
<Badge variant="paper">Paper Trading environment</Badge>
</div>

<Disclosure variant="detailed" context="primary" className="mt-4" />

{accountChangedNotice && (
<p
role="alert"
Expand Down
58 changes: 58 additions & 0 deletions client/src/components/Disclosure.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Router } from 'wouter';
import { Disclosure } from './Disclosure';

function renderWithRouter(ui: React.ReactElement) {
return render(<Router>{ui}</Router>);
}

describe('Disclosure', () => {
it('renders the compact primary copy with a link to the methodology page', () => {
renderWithRouter(<Disclosure variant="compact" context="primary" />);
expect(screen.getByText(/Paper trading only/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /learn more/i })).toHaveAttribute('href', '/methodology');
});

it('renders the detailed signals copy, distinct from the primary copy', () => {
renderWithRouter(<Disclosure variant="detailed" context="signals" />);
expect(screen.getByText(/Signal strength/)).toBeInTheDocument();
expect(screen.getByText(/not a calibrated probability/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /read the full methodology/i })).toHaveAttribute('href', '/methodology');
});

it('renders distinct copy for every context, never sharing text across contexts', () => {
const contexts = ['primary', 'signals', 'backtesting', 'paper-fills'] as const;
const compactTexts = contexts.map((context) => {
const { container, unmount } = renderWithRouter(<Disclosure variant="compact" context={context} />);
const text = container.querySelector('p')?.textContent;
unmount();
return text;
});
expect(new Set(compactTexts).size).toBe(contexts.length);
});

it('never uses banned phrases in any context/variant combination', () => {
const banned = [
'guaranteed',
'safe profit',
'high-confidence winner',
'best trade',
'cannot lose',
'proven return',
'buy now',
'sell now',
];
const contexts = ['primary', 'signals', 'backtesting', 'paper-fills'] as const;
for (const context of contexts) {
for (const variant of ['compact', 'detailed'] as const) {
const { container, unmount } = renderWithRouter(<Disclosure variant={variant} context={context} />);
const text = container.textContent?.toLowerCase() ?? '';
for (const phrase of banned) {
expect(text).not.toContain(phrase);
}
unmount();
}
}
});
});
83 changes: 83 additions & 0 deletions client/src/components/Disclosure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Info, TriangleAlert } from 'lucide-react';
import { Link } from 'wouter';
import { cn } from '../lib/utils';

/**
* DISCLOSURE-001's single reusable disclosure component. Copy here must
* match docs/product/paper-trading-and-educational-scope.md exactly --
* that document is the source of truth; this file implements it.
*
* `compact` is a persistent, low-visual-weight reminder (rendered once in
* the app shell sidebar, not duplicated per-page). `detailed` is a fuller
* explanation placed near the specific surface it contextualizes (Signals
* page, order ticket). Both use an icon *and* text, never color alone, to
* convey "this is informational/cautionary" -- the mission's explicit
* accessibility requirement for disclosure text.
*/

export type DisclosureContext = 'primary' | 'signals' | 'backtesting' | 'paper-fills';

const COPY: Record<DisclosureContext, { compact: string; detailed: string }> = {
primary: {
compact: 'Paper trading only -- simulated fills, real market data, no real money.',
detailed:
'LiquidAlpha is an educational paper-trading simulator. Prices are sourced live from Hyperliquid; every trade, fill, and result is simulated. Nothing here is financial advice, and no path in this product can place a real order on any exchange.',
},
signals: {
compact: 'Signal strength reflects indicator agreement, not a probability of winning.',
detailed:
'Signals are generated from real technical indicators (EMA, MACD, RSI, ADX, Fisher Transform, Keltner Channel), composed into a versioned, explainable 0-100 Signal strength score. This score measures how strongly the indicators agree with each other -- it is not a calibrated probability, a win-rate estimate, or investment advice. Past indicator agreement does not predict future price movement.',
},
backtesting: {
compact: 'Backtest results are historical simulations, not a guarantee of future performance.',
detailed:
"Backtests replay historical Hyperliquid candles through this platform's real signal-generation logic, with documented, conservative assumptions about entry timing, slippage, fees, and funding. A strategy's historical simulated performance is not a guarantee, promise, or reliable predictor of how it would perform going forward -- markets change, and this engine cannot account for conditions it hasn't seen.",
},
'paper-fills': {
compact: 'Simulated fill -- not a real exchange execution.',
detailed:
'Every paper fill is priced from a real Hyperliquid reference price, with documented simulated slippage and fees applied on top. No real order is ever sent to Hyperliquid or any other exchange.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop claiming that paper fills include unmodeled costs

For every order confirmation, this says all fills have slippage and fees applied, but paperEngine.ts applies slippage only to market orders, fills limit orders at the exact limit price, and stores no fee at all; the same OrderTicket even states that no fee is calculated. Users will therefore assume reported paper P&L is net of costs when it is not, so the disclosure must match the current engine or those costs must actually be modeled.

Useful? React with 👍 / 👎.

},
};

export interface DisclosureProps {
variant: 'compact' | 'detailed';
context: DisclosureContext;
className?: string;
}

export function Disclosure({ variant, context, className }: DisclosureProps) {
const copy = COPY[context];

if (variant === 'compact') {
return (
<div
className={cn(
'flex items-start gap-2 rounded-lg border border-border-subtle bg-bg-floating/60 px-3 py-2 text-xs leading-relaxed text-ink-muted',
className,
)}
>
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gold-400" aria-hidden />
<p>
{copy.compact}{' '}
<Link href="/methodology" className="underline hover:text-ink-secondary">
Learn more
</Link>
</p>
</div>
);
}

return (
<div className={cn('rounded-lg border border-gold-500/30 bg-gold-500/10 p-4', className)}>
<div className="flex items-center gap-2 text-sm font-medium text-gold-400">
<TriangleAlert className="h-4 w-4" aria-hidden />
<span>Educational simulation</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-ink-secondary">{copy.detailed}</p>
<Link href="/methodology" className="mt-2 inline-block text-xs font-medium text-gold-400 underline">
Read the full methodology
</Link>
</div>
);
}
3 changes: 3 additions & 0 deletions client/src/features/execution/OrderTicket.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { formatPrice } from '../../lib/format';
import { ApiError } from '../../lib/api';
import { submitOrder as submitOrderRequest } from './api';
import { useMarkets } from '../markets/useMarkets';
import { Disclosure } from '../../components/Disclosure';
import type { Side, OrderType } from './types';

const ASSETS = ['BTC', 'ETH', 'SOL'] as const;
Expand Down Expand Up @@ -218,6 +219,8 @@ export function OrderTicket() {
Paper Trading -- simulated fill, no real exchange
</Badge>

<Disclosure variant="detailed" context="paper-fills" className="mt-3" />

<div className="mt-4 flex flex-col gap-2 rounded-lg bg-bg-floating/60 p-4 text-sm">
<Row label="Asset" value={asset} />
<Row
Expand Down
121 changes: 121 additions & 0 deletions client/src/routes/MethodologyPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Link } from 'wouter';
import { ArrowLeft } from 'lucide-react';

/**
* DISCLOSURE-001's methodology page. Content here must stay in sync with
* docs/product/paper-trading-and-educational-scope.md -- that document is
* the source of truth; this page is its user-facing rendering.
*/

function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="border-t border-border-subtle pt-6 first:border-t-0 first:pt-0">
<h2 className="font-display text-lg font-medium tracking-tight text-ink-primary">{title}</h2>
<div className="mt-2 flex flex-col gap-2 text-sm leading-relaxed text-ink-secondary">{children}</div>
</section>
);
}

export function MethodologyPage() {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-8">
<div>
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-ink-muted hover:text-ink-secondary">
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
Back
</Link>
<h1 className="mt-3 font-display text-3xl font-medium tracking-tight text-ink-primary">Methodology</h1>
<p className="mt-2 text-sm text-ink-secondary">
What LiquidAlpha is, what every number on this platform means, and exactly which assumptions produced it.
</p>
</div>

<Section title="What this is">
<p>
An educational paper-trading simulator for Hyperliquid perpetuals. Every price is real -- sourced live from
Hyperliquid, with an explicitly-labeled CoinGecko fallback. Every trade, fill, fee, funding charge, and
backtest result is simulated. No real money, no real orders, no real exchange, ever -- this platform has no
code path that can sign or submit a real order.
</p>
<p>Not financial advice. Not a signal service. Not a broker, exchange, or custodian.</p>
</Section>

<Section title="Data sources">
<p>
Hyperliquid is the primary source for prices, candles, and funding history. If Hyperliquid becomes
unreachable, market data falls back to CoinGecko, and every price/candle carries an explicit source label so
it's never presented as Hyperliquid-sourced when it isn't. The platform's own health status --{' '}
Comment on lines +45 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish price fallback from candle behavior

When Hyperliquid candle retrieval fails, runCandleBackfillCycle explicitly has no CoinGecko fallback, and the CoinGecko adapter supplies only current price/change/volume snapshots. Saying market data falls back and every price/candle is labeled implies charts continue receiving CoinGecko candles, whereas candle history simply stops updating; describe the fallback as current-price-only and explain the candle outage behavior.

Useful? React with 👍 / 👎.

<code className="rounded bg-bg-floating px-1 py-0.5 text-xs">live</code>,{' '}
<code className="rounded bg-bg-floating px-1 py-0.5 text-xs">degraded</code>,{' '}
<code className="rounded bg-bg-floating px-1 py-0.5 text-xs">fallback</code>, or{' '}
<code className="rounded bg-bg-floating px-1 py-0.5 text-xs">unavailable</code> -- reflects which of these is
currently true.
</p>
</Section>

<Section title="Signal calculation">
<p>
Signals are generated from real technical indicators -- EMA50/EMA200, MACD, RSI, ADX, Fisher Transform, and
Keltner Channel -- computed from historical price data. A signal only fires when the underlying trend (EMA)
and momentum (MACD) agree on direction; disagreement produces no signal at all, never a forced call.
</p>
</Section>

<Section title="What Signal strength means">
<p>
Signal strength is a deterministic, versioned 0-100 score composed of six weighted components: trend
agreement, momentum agreement, trend-strength confirmation, volatility suitability, data freshness, and
indicator availability. It measures how strongly the available indicators agree with each other and with the
underlying trend -- it is not a probability of winning, a confidence level, or an expected return. Nothing on
this platform has been backtested to establish a real relationship between this score and actual trade
outcomes.
</p>
</Section>

<Section title="Backtesting assumptions">
<p>
Backtests replay historical Hyperliquid candles through the exact same signal-generation logic that produces
live signals, with a strict no-lookahead guarantee: a signal decision at any point in history only ever sees
data that would genuinely have been available at that moment. A fired signal enters at the next candle's open
(never the signal candle's own close), with documented slippage, fees, and (optionally) funding applied.
Same-candle stop/target collisions resolve conservatively as a loss. Results below 10 trades show nothing;
Comment on lines +77 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not publish guarantees for a nonexistent backtest engine

The reviewed tree has no backtest engine or results implementation, and a repo-wide tracked-file search finds no referenced backtesting-methodology.md, yet this section presents next-open entry, collision handling, costs, and sample tiers as implemented guarantees. Publishing these assertions now makes the methodology misleading and risks constraining a later implementation accidentally; mark them as planned/deferred or add the engine and its authoritative methodology first.

Useful? React with 👍 / 👎.

results below 30 show only basic figures; full statistics require 30+ trades -- the same sample-adequacy
discipline applied to live paper-trading performance metrics.
</p>
</Section>

<Section title="Paper-fill assumptions">
<p>
Every paper fill is priced from a real Hyperliquid (or labeled-fallback) reference price, with documented
simulated slippage and a flat fee assumption applied on top. Open positions accrue real Hyperliquid funding
rates over time, pro-rated by how long the position has actually been open. A liquidation price shown for a
leveraged position is an estimate using a single flat maintenance-margin assumption -- not Hyperliquid's real,
per-asset, tiered margin schedule, and not accounting for funding already paid.
Comment on lines +90 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove nonexistent funding and liquidation behavior

For any open leveraged position, this tells users that funding is accrued and a liquidation estimate is displayed, but paperEngine.ts computes P&L solely from entry and exit prices, the position schema has no funding or liquidation fields, and a repo-wide client search finds no liquidation-price display. This materially overstates the costs and risk incorporated into paper results; either implement these mechanics or document them as absent limitations.

Useful? React with 👍 / 👎.

</p>
</Section>

<Section title="Limitations">
<ul className="list-disc pl-5">
<li>Perpetuals only -- no spot-market simulation.</li>
<li>No reduce-only order behavior.</li>
<li>No cross-margin portfolio simulation -- each position is modeled independently.</li>
<li>
A single flat fee and a single flat maintenance-margin assumption, not Hyperliquid's real tiered schedules.
</li>
<li>
Backtests use one documented entry-timing/exit assumption set -- not every possible execution strategy.
</li>
</ul>
</Section>

<Section title="Versioning">
<p>
Every number this platform shows is traceable to the exact model version that produced it: a signal-engine
rule version, a Signal-strength score-model version, a paper-fill model version, and a backtest engine
version. When any of these change, results computed under the old and new versions remain distinguishable from
each other.
</p>
</Section>
</div>
);
}
3 changes: 3 additions & 0 deletions client/src/routes/SignalsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { queryKeys } from '../lib/queryKeys';
import { fetchSignals } from '../features/signals/api';
import { SignalCard } from '../features/signals/SignalCard';
import { useMarkets } from '../features/markets/useMarkets';
import { Disclosure } from '../components/Disclosure';

const PAGE_SIZE = 12;

Expand All @@ -30,6 +31,8 @@ export function SignalsPage() {
</p>
</div>

<Disclosure variant="detailed" context="signals" />

{signals.isLoading && <p className="text-sm text-ink-muted">Loading signals…</p>}
{signals.isError && <p className="text-sm text-short">Could not load signals.</p>}

Expand Down
13 changes: 12 additions & 1 deletion docs/architecture/current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,18 @@ PR #6), `db/schema.ts` (Drizzle schema, hardened FKs/indexes/enums, PR #8).
`SettingsPage` (+ `nav.ts` for route/nav config). `AnalyticsPage` (PR for
`feat/analytics-integrity`, migration step 15) and `SettingsPage`'s risk-limits form
(PR #25) were the two client surfaces still outstanding as of earlier revisions of
this doc -- both now exist.
this doc -- both now exist. `MethodologyPage` (`DISCLOSURE-001`, issue #40) added as
a route not in the main sidebar nav -- reachable only via a `Disclosure` component's
"Learn more"/"Read the full methodology" link, matching the issue's "not one giant
warning box everywhere" scoping.
- `components/Disclosure.tsx` (`DISCLOSURE-001`, issue #40) — the single reusable
disclosure component (`variant="compact" | "detailed"`, `context="primary" | "signals"
| "backtesting" | "paper-fills"`), icon + text (never color alone). `compact` renders
once in the `AppShell` sidebar footer (visible on every authenticated page) and on
`ConnectScreen`; `detailed` is placed contextually on `SignalsPage` and in
`OrderTicket`'s confirmation step. `backtesting` copy exists but has nowhere to render
yet -- no backtest results UI exists in the client (`BACKTEST-001` scoped that out).
Copy source of truth: `docs/product/paper-trading-and-educational-scope.md`.
- `features/{auth,execution,markets,positions,realtime,risk,settings,signals}/` —
feature-scoped hooks and logic (the pattern the migration plan explicitly modeled on
Replit's `features/trade`/`features/markets` shape, minus the duplication issues
Expand Down
Loading
Loading