add paper trading: mark a setup as taken, track open P&L, sell
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:
@@ -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")
|
||||||
@@ -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.tickers import router as tickers_router
|
||||||
from app.routers.jobs import router as jobs_router
|
from app.routers.jobs import router as jobs_router
|
||||||
from app.routers.market import router as market_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:
|
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(watchlist_router, prefix="/api/v1")
|
||||||
app.include_router(jobs_router, prefix="/api/v1")
|
app.include_router(jobs_router, prefix="/api/v1")
|
||||||
app.include_router(market_router, prefix="/api/v1")
|
app.include_router(market_router, prefix="/api/v1")
|
||||||
|
app.include_router(paper_trades_router, prefix="/api/v1")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.models.trade_setup import TradeSetup
|
|||||||
from app.models.watchlist import WatchlistEntry
|
from app.models.watchlist import WatchlistEntry
|
||||||
from app.models.settings import SystemSetting, IngestionProgress
|
from app.models.settings import SystemSetting, IngestionProgress
|
||||||
from app.models.alert import AlertLog
|
from app.models.alert import AlertLog
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Ticker",
|
"Ticker",
|
||||||
@@ -24,4 +25,5 @@ __all__ = [
|
|||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
"IngestionProgress",
|
"IngestionProgress",
|
||||||
"AlertLog",
|
"AlertLog",
|
||||||
|
"PaperTrade",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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})
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 type { TradeSetup } from '../../lib/types';
|
||||||
import { formatPrice, formatPercent } from '../../lib/format';
|
import { formatPrice, formatPercent } from '../../lib/format';
|
||||||
|
import { useCreatePaperTrade } from '../../hooks/usePaperTrades';
|
||||||
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
|
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
|
||||||
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
|
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
|
||||||
import { positionSize } from '../../lib/position';
|
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 sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
|
||||||
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-direction={setup.direction}
|
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>
|
<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} />
|
<TargetTable setup={setup} />
|
||||||
|
|
||||||
{setup.conflict_flags.length > 0 && (
|
{setup.conflict_flags.length > 0 && (
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -179,6 +179,21 @@ export interface SentimentProviderConfig {
|
|||||||
custom_base_url_providers: string[];
|
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 {
|
export interface BacktestBucket {
|
||||||
total: number;
|
total: number;
|
||||||
wins: number;
|
wins: number;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useMarketRegime } from '../hooks/useMarketRegime';
|
|||||||
import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
|
import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
|
||||||
import { Callout } from '../components/ui/Callout';
|
import { Callout } from '../components/ui/Callout';
|
||||||
import { Section } from '../components/ui/Section';
|
import { Section } from '../components/ui/Section';
|
||||||
|
import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
|
||||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||||
import { formatPrice } from '../lib/format';
|
import { formatPrice } from '../lib/format';
|
||||||
import { recommendationActionLabel } from '../lib/recommendation';
|
import { recommendationActionLabel } from '../lib/recommendation';
|
||||||
@@ -150,6 +151,9 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Open paper trades */}
|
||||||
|
<OpenTradesPanel />
|
||||||
|
|
||||||
<div className="grid gap-8 xl:grid-cols-5">
|
<div className="grid gap-8 xl:grid-cols-5">
|
||||||
{/* Top setups */}
|
{/* Top setups */}
|
||||||
<div className="xl:col-span-3">
|
<div className="xl:col-span-3">
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user