add paper trading: mark a setup as taken, track open P&L, sell
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s

New paper_trades table (migration 007) + service/router. "Mark as taken" on each
setup card (shares prefilled from position sizing, entry from current price, both
editable) records a simulated trade. Overview gains an Open Trades table that
marks each position to the latest close — P&L in $, %, and R-multiples — with a
total unrealized P&L footer and a Sell button to close at the current price.
Closed trades are retained for future realized-P&L reporting.

Deploy: alembic upgrade (new paper_trades table).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 06:33:56 +02:00
parent 050abc6f71
commit a69557f5d8
16 changed files with 736 additions and 1 deletions
+45
View File
@@ -0,0 +1,45 @@
"""add paper_trades table
Revision ID: 007
Revises: 006
Create Date: 2026-06-16 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "007"
down_revision: Union[str, None] = "006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"paper_trades",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("ticker_id", sa.Integer(), nullable=False),
sa.Column("direction", sa.String(length=10), nullable=False),
sa.Column("entry_price", sa.Float(), nullable=False),
sa.Column("shares", sa.Float(), nullable=False),
sa.Column("stop_loss", sa.Float(), nullable=False),
sa.Column("target", sa.Float(), nullable=False),
sa.Column("status", sa.String(length=10), nullable=False),
sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("close_price", sa.Float(), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["ticker_id"], ["tickers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_paper_trades_user_status", "paper_trades", ["user_id", "status"])
def downgrade() -> None:
op.drop_index("ix_paper_trades_user_status", table_name="paper_trades")
op.drop_table("paper_trades")
+2
View File
@@ -84,6 +84,7 @@ from app.routers.sr_levels import router as sr_levels_router
from app.routers.tickers import router as tickers_router
from app.routers.jobs import router as jobs_router
from app.routers.market import router as market_router
from app.routers.paper_trades import router as paper_trades_router
def _configure_logging() -> None:
@@ -162,3 +163,4 @@ app.include_router(trades_router, prefix="/api/v1")
app.include_router(watchlist_router, prefix="/api/v1")
app.include_router(jobs_router, prefix="/api/v1")
app.include_router(market_router, prefix="/api/v1")
app.include_router(paper_trades_router, prefix="/api/v1")
+2
View File
@@ -9,6 +9,7 @@ from app.models.trade_setup import TradeSetup
from app.models.watchlist import WatchlistEntry
from app.models.settings import SystemSetting, IngestionProgress
from app.models.alert import AlertLog
from app.models.paper_trade import PaperTrade
__all__ = [
"Ticker",
@@ -24,4 +25,5 @@ __all__ = [
"SystemSetting",
"IngestionProgress",
"AlertLog",
"PaperTrade",
]
+36
View File
@@ -0,0 +1,36 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class PaperTrade(Base):
"""A simulated ('taken') trade for paper trading.
Captured from a setup at the moment the user marks it taken: direction,
entry, size, stop and target. Open trades are marked-to-market against the
latest close; closing records the exit price and time.
"""
__tablename__ = "paper_trades"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
ticker_id: Mapped[int] = mapped_column(
ForeignKey("tickers.id", ondelete="CASCADE"), nullable=False
)
direction: Mapped[str] = mapped_column(String(10), nullable=False)
entry_price: Mapped[float] = mapped_column(Float, nullable=False)
shares: Mapped[float] = mapped_column(Float, nullable=False)
stop_loss: Mapped[float] = mapped_column(Float, nullable=False)
target: Mapped[float] = mapped_column(Float, nullable=False)
status: Mapped[str] = mapped_column(String(10), nullable=False, default="open")
opened_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+69
View File
@@ -0,0 +1,69 @@
"""Paper trades router — take, list, and close simulated trades."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access
from app.models.user import User
from app.schemas.common import APIEnvelope
from app.schemas.paper_trade import PaperTradeClose, PaperTradeCreate, PaperTradeResponse
from app.services import paper_trade_service
router = APIRouter(tags=["paper-trades"])
def _resp(trade, symbol: str, current_price=None) -> dict:
return PaperTradeResponse(
id=trade.id,
symbol=symbol,
direction=trade.direction,
entry_price=trade.entry_price,
shares=trade.shares,
stop_loss=trade.stop_loss,
target=trade.target,
status=trade.status,
opened_at=trade.opened_at,
close_price=trade.close_price,
closed_at=trade.closed_at,
current_price=current_price,
).model_dump(mode="json")
@router.get("/paper-trades", response_model=APIEnvelope)
async def list_paper_trades(
status: str | None = Query(default=None, pattern=r"^(open|closed)$"),
user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
rows = await paper_trade_service.list_trades(db, user.id, status=status)
data = [PaperTradeResponse(**r).model_dump(mode="json") for r in rows]
return APIEnvelope(status="success", data=data)
@router.post("/paper-trades", response_model=APIEnvelope, status_code=201)
async def create_paper_trade(
body: PaperTradeCreate,
user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
trade = await paper_trade_service.create_trade(
db, user.id,
symbol=body.symbol,
direction=body.direction,
entry_price=body.entry_price,
shares=body.shares,
stop_loss=body.stop_loss,
target=body.target,
)
return APIEnvelope(status="success", data=_resp(trade, body.symbol.strip().upper()))
@router.post("/paper-trades/{trade_id}/close", response_model=APIEnvelope)
async def close_paper_trade(
trade_id: int,
body: PaperTradeClose,
user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
trade = await paper_trade_service.close_trade(db, user.id, trade_id, body.close_price)
return APIEnvelope(status="success", data={"id": trade.id, "status": trade.status})
+35
View File
@@ -0,0 +1,35 @@
"""Schemas for paper trades."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class PaperTradeCreate(BaseModel):
symbol: str = Field(..., min_length=1, max_length=10)
direction: str = Field(..., pattern=r"^(long|short)$")
entry_price: float = Field(..., gt=0)
shares: float = Field(..., gt=0)
stop_loss: float = Field(..., gt=0)
target: float = Field(..., gt=0)
class PaperTradeClose(BaseModel):
close_price: float | None = Field(default=None, gt=0)
class PaperTradeResponse(BaseModel):
id: int
symbol: str
direction: str
entry_price: float
shares: float
stop_loss: float
target: float
status: str
opened_at: datetime
close_price: float | None = None
closed_at: datetime | None = None
current_price: float | None = None
+148
View File
@@ -0,0 +1,148 @@
"""Paper-trading service: take, mark-to-market, and close simulated trades."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError, ValidationError
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
return ticker
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
"""Latest stored close per ticker."""
if not ticker_ids:
return {}
latest = (
select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("md"))
.where(OHLCVRecord.ticker_id.in_(ticker_ids))
.group_by(OHLCVRecord.ticker_id)
.subquery()
)
stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join(
latest,
and_(
OHLCVRecord.ticker_id == latest.c.ticker_id,
OHLCVRecord.date == latest.c.md,
),
)
result = await db.execute(stmt)
return {tid: float(close) for tid, close in result.all()}
async def create_trade(
db: AsyncSession,
user_id: int,
*,
symbol: str,
direction: str,
entry_price: float,
shares: float,
stop_loss: float,
target: float,
) -> PaperTrade:
direction = direction.strip().lower()
if direction not in ("long", "short"):
raise ValidationError("direction must be 'long' or 'short'")
if shares <= 0 or entry_price <= 0:
raise ValidationError("shares and entry_price must be positive")
ticker = await _get_ticker(db, symbol)
trade = PaperTrade(
user_id=user_id,
ticker_id=ticker.id,
direction=direction,
entry_price=entry_price,
shares=shares,
stop_loss=stop_loss,
target=target,
status="open",
opened_at=datetime.now(timezone.utc),
)
db.add(trade)
await db.commit()
await db.refresh(trade)
return trade
def _to_dict(trade: PaperTrade, symbol: str, current_price: float | None) -> dict:
return {
"id": trade.id,
"symbol": symbol,
"direction": trade.direction,
"entry_price": trade.entry_price,
"shares": trade.shares,
"stop_loss": trade.stop_loss,
"target": trade.target,
"status": trade.status,
"opened_at": trade.opened_at,
"close_price": trade.close_price,
"closed_at": trade.closed_at,
# For open trades, mark to market; for closed, the realized exit price.
"current_price": current_price if trade.status == "open" else trade.close_price,
}
async def list_trades(
db: AsyncSession,
user_id: int,
status: str | None = None,
) -> list[dict]:
stmt = (
select(PaperTrade, Ticker.symbol)
.join(Ticker, PaperTrade.ticker_id == Ticker.id)
.where(PaperTrade.user_id == user_id)
)
if status is not None:
stmt = stmt.where(PaperTrade.status == status)
stmt = stmt.order_by(PaperTrade.opened_at.desc())
rows = (await db.execute(stmt)).all()
open_ids = {t.ticker_id for t, _ in rows if t.status == "open"}
prices = await _latest_closes(db, open_ids)
return [_to_dict(t, sym, prices.get(t.ticker_id)) for t, sym in rows]
async def close_trade(
db: AsyncSession,
user_id: int,
trade_id: int,
close_price: float | None = None,
) -> PaperTrade:
result = await db.execute(
select(PaperTrade).where(
PaperTrade.id == trade_id,
PaperTrade.user_id == user_id,
)
)
trade = result.scalar_one_or_none()
if trade is None:
raise NotFoundError(f"Paper trade not found: {trade_id}")
if trade.status == "closed":
raise ValidationError("Trade is already closed")
if close_price is None:
prices = await _latest_closes(db, {trade.ticker_id})
close_price = prices.get(trade.ticker_id)
if close_price is None:
raise ValidationError("No current price available to close at; supply close_price")
trade.status = "closed"
trade.close_price = float(close_price)
trade.closed_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(trade)
return trade
+29
View File
@@ -0,0 +1,29 @@
import apiClient from './client';
import type { PaperTrade } from '../lib/types';
export function listPaperTrades(status?: 'open' | 'closed') {
return apiClient
.get<PaperTrade[]>('paper-trades', { params: status ? { status } : {} })
.then((r) => r.data);
}
export interface CreatePaperTradeBody {
symbol: string;
direction: 'long' | 'short';
entry_price: number;
shares: number;
stop_loss: number;
target: number;
}
export function createPaperTrade(body: CreatePaperTradeBody) {
return apiClient.post<PaperTrade>('paper-trades', body).then((r) => r.data);
}
export function closePaperTrade(id: number, closePrice?: number) {
return apiClient
.post<{ id: number; status: string }>(`paper-trades/${id}/close`, {
close_price: closePrice ?? null,
})
.then((r) => r.data);
}
@@ -0,0 +1,126 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { usePaperTrades, useClosePaperTrade } from '../../hooks/usePaperTrades';
import { tradePnl } from '../../lib/paperTrade';
import { formatPrice } from '../../lib/format';
import { Section } from '../ui/Section';
import { Callout } from '../ui/Callout';
function money(v: number): string {
const sign = v >= 0 ? '+' : '';
return `${sign}$${Math.abs(v).toFixed(2)}`;
}
function pnlColor(v: number): string {
if (v > 0) return 'text-emerald-400';
if (v < 0) return 'text-red-400';
return 'text-gray-300';
}
export function OpenTradesPanel() {
const { data: trades, isLoading } = usePaperTrades('open');
const close = useClosePaperTrade();
const totals = useMemo(() => {
let pnl = 0, winners = 0, losers = 0, priced = 0;
for (const t of trades ?? []) {
const p = tradePnl(t);
if (!p) continue;
priced += 1;
pnl += p.pnl;
if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 1;
}
return { pnl, winners, losers, priced };
}, [trades]);
if (isLoading) return null;
const rows = trades ?? [];
return (
<Section
title="Open Trades"
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}` : 'paper trading'}
>
{rows.length === 0 ? (
<Callout variant="empty">
No open paper trades. Open a ticker and tap Mark as taken on a setup to start.
</Callout>
) : (
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Ticker</th>
<th className="px-4 py-3">Dir</th>
<th className="px-4 py-3 text-right">Shares</th>
<th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">Now</th>
<th className="px-4 py-3 text-right">P&L</th>
<th className="px-4 py-3 text-right">%</th>
<th className="px-4 py-3 text-right">R</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{rows.map((t) => {
const p = tradePnl(t);
return (
<tr key={t.id} className="border-b border-white/[0.04] hover:bg-white/[0.03]">
<td className="px-4 py-3">
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
{t.symbol}
</Link>
</td>
<td className="px-4 py-3">
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
{t.direction}
</span>
</td>
<td className="num px-4 py-3 text-right text-gray-300">{t.shares}</td>
<td className="num px-4 py-3 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
<td className="num px-4 py-3 text-right text-gray-200">
{t.current_price != null ? formatPrice(t.current_price) : '—'}
</td>
<td className={`num px-4 py-3 text-right font-semibold ${p ? pnlColor(p.pnl) : 'text-gray-500'}`}>
{p ? money(p.pnl) : '—'}
</td>
<td className={`num px-4 py-3 text-right ${p ? pnlColor(p.pct) : 'text-gray-500'}`}>
{p ? `${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
</td>
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => {
if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {
close.mutate({ id: t.id });
}
}}
disabled={close.isPending}
className="rounded-md border border-white/[0.1] px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
>
Sell
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08]">
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
Total unrealized P&L
</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
{money(totals.pnl)}
</td>
<td colSpan={3} />
</tr>
</tfoot>
</table>
</div>
)}
</Section>
);
}
@@ -1,5 +1,7 @@
import { useState } from 'react';
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent } from '../../lib/format';
import { useCreatePaperTrade } from '../../hooks/usePaperTrades';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
import { positionSize } from '../../lib/position';
@@ -113,6 +115,25 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false);
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price);
const confirmTake = () => {
createTrade.mutate(
{
symbol: setup.symbol,
direction: setup.direction as 'long' | 'short',
entry_price: takeEntry,
shares: takeShares,
stop_loss: setup.stop_loss,
target: setup.target,
},
{ onSuccess: () => setTaking(false) },
);
};
return (
<div
data-direction={setup.direction}
@@ -184,6 +205,63 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
<p className="text-[11px] text-gray-600">Set account size below to size this trade.</p>
)}
{!taking ? (
<button
onClick={() => {
setTakeShares(sizing?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price);
setTaking(true);
}}
className="w-full rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-300 transition-colors hover:bg-emerald-500/20"
>
+ Mark as taken (paper trade)
</button>
) : (
<div className="rounded-md border border-white/[0.08] bg-white/[0.02] p-2.5 space-y-2">
<div className="grid grid-cols-2 gap-2">
<label className="block space-y-1">
<span className="text-[10px] uppercase tracking-wider text-gray-500">Shares</span>
<input
type="number"
min={0}
value={takeShares}
onChange={(e) => setTakeShares(Number(e.target.value))}
className="w-full input-glass px-2 py-1 text-sm num"
/>
</label>
<label className="block space-y-1">
<span className="text-[10px] uppercase tracking-wider text-gray-500">Entry</span>
<input
type="number"
min={0}
step="0.01"
value={takeEntry}
onChange={(e) => setTakeEntry(Number(e.target.value))}
className="w-full input-glass px-2 py-1 text-sm num"
/>
</label>
</div>
<p className="text-[10px] text-gray-500">
Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(setup.target)} · {setup.direction.toUpperCase()}
</p>
<div className="flex gap-2">
<button
onClick={confirmTake}
disabled={createTrade.isPending || !(takeShares > 0) || !(takeEntry > 0)}
className="flex-1 rounded-md bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-300 hover:bg-emerald-500/30 disabled:opacity-50"
>
{createTrade.isPending ? 'Taking…' : 'Confirm'}
</button>
<button
onClick={() => setTaking(false)}
className="rounded-md border border-white/[0.08] px-3 py-1.5 text-xs text-gray-400 hover:text-gray-200"
>
Cancel
</button>
</div>
</div>
)}
<TargetTable setup={setup} />
{setup.conflict_flags.length > 0 && (
+38
View File
@@ -0,0 +1,38 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/paperTrades';
import { useToast } from '../components/ui/Toast';
export function usePaperTrades(status?: 'open' | 'closed') {
return useQuery({
queryKey: ['paper-trades', status ?? 'all'],
queryFn: () => api.listPaperTrades(status),
refetchInterval: 60_000,
});
}
export function useCreatePaperTrade() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (body: api.CreatePaperTradeBody) => api.createPaperTrade(body),
onSuccess: (t) => {
qc.invalidateQueries({ queryKey: ['paper-trades'] });
addToast('success', `Taken: ${t.shares} ${t.symbol} ${t.direction.toUpperCase()} @ ${t.entry_price}`);
},
onError: (e: Error) => addToast('error', e.message || 'Failed to take trade'),
});
}
export function useClosePaperTrade() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: ({ id, closePrice }: { id: number; closePrice?: number }) =>
api.closePaperTrade(id, closePrice),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['paper-trades'] });
addToast('success', 'Trade closed.');
},
onError: (e: Error) => addToast('error', e.message || 'Failed to close trade'),
});
}
+25
View File
@@ -0,0 +1,25 @@
import type { PaperTrade } from './types';
export interface TradePnl {
/** Reference price: live close for open trades, exit price for closed. */
ref: number;
perShare: number;
pnl: number;
pct: number;
/** Profit in R-multiples (relative to the per-share risk), null if no risk. */
r: number | null;
}
export function tradePnl(t: PaperTrade): TradePnl | null {
const ref = t.current_price;
if (ref == null || !t.entry_price) return null;
const perShare = t.direction === 'long' ? ref - t.entry_price : t.entry_price - ref;
const risk = Math.abs(t.entry_price - t.stop_loss);
return {
ref,
perShare,
pnl: perShare * t.shares,
pct: (perShare / t.entry_price) * 100,
r: risk > 0 ? perShare / risk : null,
};
}
+15
View File
@@ -179,6 +179,21 @@ export interface SentimentProviderConfig {
custom_base_url_providers: string[];
}
export interface PaperTrade {
id: number;
symbol: string;
direction: 'long' | 'short';
entry_price: number;
shares: number;
stop_loss: number;
target: number;
status: 'open' | 'closed';
opened_at: string;
close_price: number | null;
closed_at: string | null;
current_price: number | null;
}
export interface BacktestBucket {
total: number;
wins: number;
+4
View File
@@ -8,6 +8,7 @@ import { useMarketRegime } from '../hooks/useMarketRegime';
import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
import { Callout } from '../components/ui/Callout';
import { Section } from '../components/ui/Section';
import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { formatPrice } from '../lib/format';
import { recommendationActionLabel } from '../lib/recommendation';
@@ -150,6 +151,9 @@ export default function DashboardPage() {
</div>
)}
{/* Open paper trades */}
<OpenTradesPanel />
<div className="grid gap-8 xl:grid-cols-5">
{/* Top setups */}
<div className="xl:col-span-3">
+1 -1
View File
@@ -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/market.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/backtestpanel.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/usemarketregime.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/regime.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/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.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/dashboard/opentradespanel.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/backtestpanel.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/usemarketregime.ts","./src/hooks/usepapertrades.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/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.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"}
+83
View File
@@ -0,0 +1,83 @@
"""Tests for the paper-trading service."""
from __future__ import annotations
from datetime import date, datetime, timezone
import pytest
from app.exceptions import ValidationError
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.models.user import User
from app.services import paper_trade_service as svc
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
async def _seed(session, symbol: str, close: float) -> int:
user = await session.get(User, 1)
if user is None:
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
session.add(OHLCVRecord(ticker_id=t.id, date=date.today(),
open=close, high=close, low=close, close=close, volume=1))
await session.commit()
return t.id
async def test_create_and_list_open(session):
await _seed(session, "AAA", close=110.0)
await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=10, stop_loss=95.0, target=120.0)
rows = await svc.list_trades(session, 1)
assert len(rows) == 1
row = rows[0]
assert row["symbol"] == "AAA"
assert row["status"] == "open"
assert row["current_price"] == 110.0 # marked to the latest close
async def test_close_uses_current_price(session):
await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=5, stop_loss=95.0, target=120.0)
closed = await svc.close_trade(session, 1, trade.id)
assert closed.status == "closed"
assert closed.close_price == 112.0
assert closed.closed_at is not None
rows = await svc.list_trades(session, 1, status="closed")
assert rows[0]["current_price"] == 112.0 # closed → realized exit
async def test_close_with_explicit_price(session):
await _seed(session, "AAA", close=112.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="short",
entry_price=100.0, shares=5, stop_loss=105.0, target=90.0)
closed = await svc.close_trade(session, 1, trade.id, close_price=93.0)
assert closed.close_price == 93.0
async def test_invalid_direction_rejected(session):
await _seed(session, "AAA", close=100.0)
with pytest.raises(ValidationError):
await svc.create_trade(session, 1, symbol="AAA", direction="sideways",
entry_price=100.0, shares=1, stop_loss=95.0, target=110.0)
async def test_double_close_rejected(session):
await _seed(session, "AAA", close=100.0)
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
entry_price=100.0, shares=1, stop_loss=95.0, target=110.0)
await svc.close_trade(session, 1, trade.id)
with pytest.raises(ValidationError):
await svc.close_trade(session, 1, trade.id)