diff --git a/alembic/versions/007_add_paper_trades.py b/alembic/versions/007_add_paper_trades.py new file mode 100644 index 0000000..58fa7b5 --- /dev/null +++ b/alembic/versions/007_add_paper_trades.py @@ -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") diff --git a/app/main.py b/app/main.py index 383b536..0463a1a 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models/__init__.py b/app/models/__init__.py index d456fc6..7b89dd8 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", ] diff --git a/app/models/paper_trade.py b/app/models/paper_trade.py new file mode 100644 index 0000000..a457e59 --- /dev/null +++ b/app/models/paper_trade.py @@ -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) diff --git a/app/routers/paper_trades.py b/app/routers/paper_trades.py new file mode 100644 index 0000000..a7edbf1 --- /dev/null +++ b/app/routers/paper_trades.py @@ -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}) diff --git a/app/schemas/paper_trade.py b/app/schemas/paper_trade.py new file mode 100644 index 0000000..b3f8e1f --- /dev/null +++ b/app/schemas/paper_trade.py @@ -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 diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py new file mode 100644 index 0000000..1c17621 --- /dev/null +++ b/app/services/paper_trade_service.py @@ -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 diff --git a/frontend/src/api/paperTrades.ts b/frontend/src/api/paperTrades.ts new file mode 100644 index 0000000..f9235f6 --- /dev/null +++ b/frontend/src/api/paperTrades.ts @@ -0,0 +1,29 @@ +import apiClient from './client'; +import type { PaperTrade } from '../lib/types'; + +export function listPaperTrades(status?: 'open' | 'closed') { + return apiClient + .get('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('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); +} diff --git a/frontend/src/components/dashboard/OpenTradesPanel.tsx b/frontend/src/components/dashboard/OpenTradesPanel.tsx new file mode 100644 index 0000000..cf77894 --- /dev/null +++ b/frontend/src/components/dashboard/OpenTradesPanel.tsx @@ -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 ( +
0 ? `${rows.length} open · ${totals.winners}▲ ${totals.losers}▼` : 'paper trading'} + > + {rows.length === 0 ? ( + + No open paper trades. Open a ticker and tap “Mark as taken” on a setup to start. + + ) : ( +
+ + + + + + + + + + + + + + + + {rows.map((t) => { + const p = tradePnl(t); + return ( + + + + + + + + + + + + ); + })} + + + + + + + +
TickerDirSharesEntryNowP&L%R
+ + {t.symbol} + + + + {t.direction} + + {t.shares}{formatPrice(t.entry_price)} + {t.current_price != null ? formatPrice(t.current_price) : '—'} + + {p ? money(p.pnl) : '—'} + + {p ? `${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'} + + {p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'} + + +
+ Total unrealized P&L + + {money(totals.pnl)} + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index fabd6ef..0a39d9b 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -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(sizing?.shares ?? 0); + const [takeEntry, setTakeEntry] = useState(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 (
Set account size below to size this trade.

)} + {!taking ? ( + + ) : ( +
+
+ + +
+

+ Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(setup.target)} · {setup.direction.toUpperCase()} +

+
+ + +
+
+ )} + {setup.conflict_flags.length > 0 && ( diff --git a/frontend/src/hooks/usePaperTrades.ts b/frontend/src/hooks/usePaperTrades.ts new file mode 100644 index 0000000..44d84ad --- /dev/null +++ b/frontend/src/hooks/usePaperTrades.ts @@ -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'), + }); +} diff --git a/frontend/src/lib/paperTrade.ts b/frontend/src/lib/paperTrade.ts new file mode 100644 index 0000000..56566b7 --- /dev/null +++ b/frontend/src/lib/paperTrade.ts @@ -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, + }; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 09baa32..dc0e0e0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 7f65d35..6ea79f3 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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() {
)} + {/* Open paper trades */} + +
{/* Top setups */}
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index dbd1c45..5bd905b 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/tests/unit/test_paper_trade_service.py b/tests/unit/test_paper_trade_service.py new file mode 100644 index 0000000..ef27fad --- /dev/null +++ b/tests/unit/test_paper_trade_service.py @@ -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)