feat: shadow book + shadow-vs-manual performance comparison
The manual paper book only contains trades taken by hand, inside a 20
minute window, on days someone was available. The backtest that validated
this strategy auto-takes the top-ranked qualified setups up to capacity
every session. The forward record was therefore measuring strategy plus
discretion plus availability -- and degrading silently on busy days.
The shadow book closes that gap: it mirrors the backtest's selection rule
(top strategy_rank qualified, up to capacity, 1% fixed-fractional risk)
and shares the manual book's exit policy, so the only difference between
the two books is which setups get taken. Selection ordering reuses the
strategy_rank the scanner already stores rather than recomputing it, so
the two cannot drift apart. It runs as a near-close pipeline step right
after the scan, marking entries at the same prices a human would see.
Gate-reset re-entry state is now scoped per book -- the books diverge as
soon as their entries differ, and each must see only its own stops.
Performance view rewritten around the comparison:
- three series (shadow, manual, SPY) from a new endpoint
- SPY changes from a per-trade cost-basis counterfactual to plain
buy-and-hold %, since one line has to serve two books
- headline stats are R-multiples, not currency: the books size
differently, so only R compares across them
- configurable start date, because the strategy has been revised
repeatedly and pre-cutover trades ran under rules that no longer
exist
Migration 024 also repairs the numeric weekday crons written by 023,
rewriting only rows still holding the broken form so hand-corrected
settings survive. Its literals are inlined because bound parameters
render as NULL under 'alembic upgrade --sql'.
The shadow book is opt-in and writes nothing until enabled. Verify its
first selections match a backtest of that day's cross-section before
trusting any point on the curve.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
"""paper trade book tag (manual vs shadow) + weekday cron repair
|
||||||
|
|
||||||
|
Revision ID: 024
|
||||||
|
Revises: 023
|
||||||
|
Create Date: 2026-07-20 00:00:00.000000
|
||||||
|
|
||||||
|
Two things ship together because both are corrections to 023's stored state.
|
||||||
|
|
||||||
|
1. ``paper_trades.book`` separates the discretionary book from the automatic
|
||||||
|
shadow book. Everything that exists today was opened by hand, so the
|
||||||
|
backfill value is "manual".
|
||||||
|
|
||||||
|
2. 023 wrote weekday crons with a numeric day-of-week. APScheduler's
|
||||||
|
from_crontab() feeds field 5 to its own day_of_week where 0=Monday, so
|
||||||
|
"1-5" resolved to Tue-Sat: every Monday was skipped and the scanner ran on
|
||||||
|
Saturdays against stale data. Rewrite only the rows that still hold the
|
||||||
|
broken numeric form, so a hand-corrected setting is never clobbered.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "024"
|
||||||
|
down_revision: Union[str, None] = "023"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
# key -> (broken numeric form written by 023, corrected named form)
|
||||||
|
_CRON_REPAIR: dict[str, tuple[str, str]] = {
|
||||||
|
"schedule_near_close_pipeline_cron": ("30 15 * * 1-5", "30 15 * * mon-fri"),
|
||||||
|
"schedule_after_close_pipeline_cron": ("45 16 * * 1-5", "45 16 * * mon-fri"),
|
||||||
|
"schedule_intraday_pipeline_cron": ("0 10-15 * * 1-5", "0 10-15 * * mon-fri"),
|
||||||
|
"schedule_fundamentals_cron": ("0 1 * * 1", "0 1 * * mon"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# server_default backfills existing rows, so no separate UPDATE is needed.
|
||||||
|
op.add_column(
|
||||||
|
"paper_trades",
|
||||||
|
sa.Column("book", sa.String(length=10), nullable=False, server_default="manual"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Literals are inlined rather than bound because bound parameters render as
|
||||||
|
# NULL under `alembic upgrade --sql`, which would silently produce a script
|
||||||
|
# that matches nothing. Every value here is a constant defined above.
|
||||||
|
for key, (broken, fixed) in _CRON_REPAIR.items():
|
||||||
|
op.execute(
|
||||||
|
f"UPDATE system_settings SET value = '{fixed}' " # noqa: S608
|
||||||
|
f"WHERE key = '{key}' AND value = '{broken}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("paper_trades", "book")
|
||||||
|
# Crons are deliberately left corrected — restoring the numeric form would
|
||||||
|
# reintroduce the skipped-Monday bug.
|
||||||
@@ -49,3 +49,12 @@ class PaperTrade(Base):
|
|||||||
# Execution era for forward vs backtest comparison:
|
# Execution era for forward vs backtest comparison:
|
||||||
# null/legacy = pre-cutover morning-scan, "near_close" = post near-close cutover.
|
# null/legacy = pre-cutover morning-scan, "near_close" = post near-close cutover.
|
||||||
fill_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
fill_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
# Which book this trade belongs to:
|
||||||
|
# "manual" — discretionary, opened by the user from a qualified setup
|
||||||
|
# "shadow" — opened automatically by the validated strategy (top-ranked
|
||||||
|
# qualified up to capacity, 1% risk). The shadow book is the
|
||||||
|
# faithful live twin of the backtest; the two books share the
|
||||||
|
# same exit policy so the only difference is *selection*.
|
||||||
|
# Gate-reset re-entry state is tracked per book — the books diverge as soon
|
||||||
|
# as their entries differ, and each must see its own trade history.
|
||||||
|
book: Mapped[str] = mapped_column(String(10), nullable=False, default="manual")
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ from app.schemas.admin import (
|
|||||||
JobTriggerRequest,
|
JobTriggerRequest,
|
||||||
JobToggle,
|
JobToggle,
|
||||||
RecommendationConfigUpdate,
|
RecommendationConfigUpdate,
|
||||||
|
PerformanceConfigUpdate,
|
||||||
ScheduleConfigUpdate,
|
ScheduleConfigUpdate,
|
||||||
SentimentConfigUpdate,
|
SentimentConfigUpdate,
|
||||||
|
ShadowBookConfigUpdate,
|
||||||
SentimentTestRequest,
|
SentimentTestRequest,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
RegistrationToggle,
|
RegistrationToggle,
|
||||||
@@ -201,6 +203,50 @@ async def update_schedule_settings(
|
|||||||
return APIEnvelope(status="success", data=updated)
|
return APIEnvelope(status="success", data=updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/settings/performance", response_model=APIEnvelope)
|
||||||
|
async def get_performance_settings(
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return APIEnvelope(
|
||||||
|
status="success", data=await admin_service.get_performance_config(db)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/settings/performance", response_model=APIEnvelope)
|
||||||
|
async def update_performance_settings(
|
||||||
|
body: PerformanceConfigUpdate,
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
updated = await admin_service.update_performance_config(
|
||||||
|
db, body.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
return APIEnvelope(status="success", data=updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/settings/shadow-book", response_model=APIEnvelope)
|
||||||
|
async def get_shadow_book_settings(
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return APIEnvelope(
|
||||||
|
status="success", data=await admin_service.get_shadow_book_config(db)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/settings/shadow-book", response_model=APIEnvelope)
|
||||||
|
async def update_shadow_book_settings(
|
||||||
|
body: ShadowBookConfigUpdate,
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
updated = await admin_service.update_shadow_book_config(
|
||||||
|
db, body.model_dump(exclude_unset=True, exclude_none=True)
|
||||||
|
)
|
||||||
|
return APIEnvelope(status="success", data=updated)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/settings/sentiment", response_model=APIEnvelope)
|
@router.get("/admin/settings/sentiment", response_model=APIEnvelope)
|
||||||
async def get_sentiment_settings(
|
async def get_sentiment_settings(
|
||||||
_admin: User = Depends(require_admin),
|
_admin: User = Depends(require_admin),
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ async def paper_trade_equity_curve(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/paper-trades/performance", response_model=APIEnvelope)
|
||||||
|
async def paper_trade_performance(
|
||||||
|
_user: User = Depends(require_access),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> APIEnvelope:
|
||||||
|
"""Shadow book vs discretionary book vs SPY since the configured start date."""
|
||||||
|
return APIEnvelope(
|
||||||
|
status="success", data=await paper_trade_service.performance_summary(db)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/paper-trades/exit-policy", response_model=APIEnvelope)
|
@router.put("/paper-trades/exit-policy", response_model=APIEnvelope)
|
||||||
async def write_exit_policy(
|
async def write_exit_policy(
|
||||||
body: ExitPolicyUpdate,
|
body: ExitPolicyUpdate,
|
||||||
|
|||||||
+58
-1
@@ -33,7 +33,13 @@ from app.exceptions import ProviderError
|
|||||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||||
from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
||||||
from app.providers.protocol import SentimentData
|
from app.providers.protocol import SentimentData
|
||||||
from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store
|
from app.services import (
|
||||||
|
fundamental_service,
|
||||||
|
ingestion_service,
|
||||||
|
sentiment_service,
|
||||||
|
settings_store,
|
||||||
|
shadow_book_service,
|
||||||
|
)
|
||||||
from app.services.alert_service import dispatch_alerts
|
from app.services.alert_service import dispatch_alerts
|
||||||
from app.services.backtest_service import (
|
from app.services.backtest_service import (
|
||||||
BACKTEST_TARGET_MODELS,
|
BACKTEST_TARGET_MODELS,
|
||||||
@@ -610,6 +616,54 @@ async def backfill_ohlcv() -> None:
|
|||||||
await collect_ohlcv(full_backfill=True, job_name="data_backfill")
|
await collect_ohlcv(full_backfill=True, job_name="data_backfill")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_shadow_book() -> None:
|
||||||
|
"""Open the strategy's own positions from the latest qualifying scan.
|
||||||
|
|
||||||
|
The shadow book is the faithful live twin of the backtest: top-ranked
|
||||||
|
qualified setups, up to capacity, 1% risk, no human input. It runs straight
|
||||||
|
after the near-close scan so its entries are marked at the same near-close
|
||||||
|
prices the discretionary book sees, leaving *selection* as the only
|
||||||
|
difference between the two books.
|
||||||
|
|
||||||
|
Opt-in (``shadow_book_enabled``) because it writes live trades.
|
||||||
|
"""
|
||||||
|
job_name = "shadow_book"
|
||||||
|
_log_event(logging.INFO, "job_start", job=job_name)
|
||||||
|
_runtime_start(job_name, total=1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
if not await _is_job_enabled(db, job_name):
|
||||||
|
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
|
||||||
|
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
|
||||||
|
return
|
||||||
|
if not await shadow_book_service.is_enabled(db):
|
||||||
|
_log_event(logging.INFO, "job_skipped", job=job_name, reason="not enabled in settings")
|
||||||
|
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Not enabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.services.admin_service import get_activation_config
|
||||||
|
|
||||||
|
activation_config = await get_activation_config(db)
|
||||||
|
summary = await shadow_book_service.open_shadow_positions(
|
||||||
|
db, activation_config=activation_config
|
||||||
|
)
|
||||||
|
symbols = await shadow_book_service.symbols_for(db, summary["symbols"])
|
||||||
|
|
||||||
|
_runtime_progress(job_name, processed=1, total=1)
|
||||||
|
_runtime_finish(
|
||||||
|
job_name, "completed", processed=1, total=1,
|
||||||
|
message=(
|
||||||
|
f"Opened {summary['opened']} ({', '.join(symbols) if symbols else 'none'}); "
|
||||||
|
f"held {summary['skipped_held']}, gate-locked {summary['skipped_locked']}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_log_event(logging.INFO, "job_complete", job=job_name, opened=summary["opened"], symbols=symbols)
|
||||||
|
except Exception as exc:
|
||||||
|
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
||||||
|
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||||
|
|
||||||
|
|
||||||
async def collect_ohlcv_final() -> None:
|
async def collect_ohlcv_final() -> None:
|
||||||
"""After-close OHLCV refresh that replaces the day's partial bar.
|
"""After-close OHLCV refresh that replaces the day's partial bar.
|
||||||
|
|
||||||
@@ -1238,6 +1292,9 @@ _NEAR_CLOSE_PIPELINE_STEPS = [
|
|||||||
# back to the previous close and execution degrades to the stale_close floor.
|
# back to the previous close and execution degrades to the stale_close floor.
|
||||||
("data_collector", "collect_ohlcv"),
|
("data_collector", "collect_ohlcv"),
|
||||||
("rr_scanner", "scan_rr"),
|
("rr_scanner", "scan_rr"),
|
||||||
|
# Straight after the scan so shadow entries mark at the same near-close
|
||||||
|
# prices the discretionary book is looking at.
|
||||||
|
("shadow_book", "run_shadow_book"),
|
||||||
("alerts", "dispatch_alerts_job"),
|
("alerts", "dispatch_alerts_job"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ class ScheduleConfigUpdate(BaseModel):
|
|||||||
schedule_fundamentals_cron: str | None = Field(default=None, max_length=120)
|
schedule_fundamentals_cron: str | None = Field(default=None, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceConfigUpdate(BaseModel):
|
||||||
|
"""Window for the Performance comparison.
|
||||||
|
|
||||||
|
``start_date`` is an ISO date, or empty string to show all history. The
|
||||||
|
strategy has been revised repeatedly; pinning a start keeps the shadow-vs-
|
||||||
|
manual comparison inside one configuration instead of averaging across
|
||||||
|
rules that no longer exist.
|
||||||
|
"""
|
||||||
|
start_date: str | None = Field(default=None, max_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
class ShadowBookConfigUpdate(BaseModel):
|
||||||
|
"""Auto-traded shadow book: the validated strategy with no human input."""
|
||||||
|
enabled: bool | None = None
|
||||||
|
capacity: int | None = Field(default=None, ge=1, le=100)
|
||||||
|
risk_pct: float | None = Field(default=None, gt=0, le=10)
|
||||||
|
start_equity: float | None = Field(default=None, ge=1000)
|
||||||
|
|
||||||
|
|
||||||
class SentimentConfigUpdate(BaseModel):
|
class SentimentConfigUpdate(BaseModel):
|
||||||
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
|
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
|
||||||
the stored key."""
|
the stored key."""
|
||||||
|
|||||||
@@ -204,6 +204,61 @@ async def update_activation_config(
|
|||||||
return await get_activation_config(db)
|
return await get_activation_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Performance window + shadow book
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_performance_config(db: AsyncSession) -> dict:
|
||||||
|
"""Start date for the Performance comparison ('' = all history)."""
|
||||||
|
from app.services.paper_trade_service import KEY_PERFORMANCE_START
|
||||||
|
|
||||||
|
return {"start_date": await settings_store.get_value(db, KEY_PERFORMANCE_START, "") or ""}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_performance_config(db: AsyncSession, updates: dict) -> dict:
|
||||||
|
"""Set (or clear) the performance start date. Empty string means all history."""
|
||||||
|
from datetime import date as _date
|
||||||
|
|
||||||
|
from app.services.paper_trade_service import KEY_PERFORMANCE_START
|
||||||
|
|
||||||
|
if "start_date" in updates:
|
||||||
|
raw = (updates.get("start_date") or "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
_date.fromisoformat(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValidationError("start_date must be an ISO date (YYYY-MM-DD)") from exc
|
||||||
|
await update_setting(db, KEY_PERFORMANCE_START, raw)
|
||||||
|
return await get_performance_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shadow_book_config(db: AsyncSession) -> dict:
|
||||||
|
"""Shadow book switch + sizing, with the validated defaults filled in."""
|
||||||
|
from app.services import shadow_book_service
|
||||||
|
|
||||||
|
config = await shadow_book_service.get_config(db)
|
||||||
|
config["enabled"] = await shadow_book_service.is_enabled(db)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
async def update_shadow_book_config(db: AsyncSession, updates: dict) -> dict:
|
||||||
|
"""Update the shadow book. Enabling it starts automatic live entries."""
|
||||||
|
from app.services import shadow_book_service
|
||||||
|
|
||||||
|
if "enabled" in updates:
|
||||||
|
await update_setting(
|
||||||
|
db, shadow_book_service.KEY_ENABLED, "true" if updates["enabled"] else "false"
|
||||||
|
)
|
||||||
|
for key, storage_key in (
|
||||||
|
("capacity", shadow_book_service.KEY_CAPACITY),
|
||||||
|
("risk_pct", shadow_book_service.KEY_RISK_PCT),
|
||||||
|
("start_equity", shadow_book_service.KEY_START_EQUITY),
|
||||||
|
):
|
||||||
|
if key in updates:
|
||||||
|
await update_setting(db, storage_key, str(updates[key]))
|
||||||
|
return await get_shadow_book_config(db)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pipeline schedule (cron)
|
# Pipeline schedule (cron)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -569,6 +624,7 @@ VALID_JOB_NAMES = {
|
|||||||
"near_close_pipeline",
|
"near_close_pipeline",
|
||||||
"after_close_pipeline",
|
"after_close_pipeline",
|
||||||
"intraday_pipeline",
|
"intraday_pipeline",
|
||||||
|
"shadow_book",
|
||||||
}
|
}
|
||||||
|
|
||||||
JOB_LABELS = {
|
JOB_LABELS = {
|
||||||
@@ -589,6 +645,7 @@ JOB_LABELS = {
|
|||||||
"near_close_pipeline": "Near-Close Pipeline (scan+alert)",
|
"near_close_pipeline": "Near-Close Pipeline (scan+alert)",
|
||||||
"after_close_pipeline": "After-Close Pipeline (outcome)",
|
"after_close_pipeline": "After-Close Pipeline (outcome)",
|
||||||
"intraday_pipeline": "Intraday Pipeline",
|
"intraday_pipeline": "Intraday Pipeline",
|
||||||
|
"shadow_book": "Shadow Book (auto-traded strategy)",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Jobs driven by a pipeline (in order) rather than their own auto timer.
|
# Jobs driven by a pipeline (in order) rather than their own auto timer.
|
||||||
@@ -601,6 +658,7 @@ PIPELINE_MEMBERS = {
|
|||||||
"alerts",
|
"alerts",
|
||||||
"market_regime",
|
"market_regime",
|
||||||
"regime_monitor",
|
"regime_monitor",
|
||||||
|
"shadow_book",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import bisect
|
import bisect
|
||||||
|
import logging
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
@@ -20,7 +21,9 @@ from app.services.outcome_service import (
|
|||||||
Bar,
|
Bar,
|
||||||
evaluate_setup_against_bars,
|
evaluate_setup_against_bars,
|
||||||
)
|
)
|
||||||
from app.services.trade_policy import get_reentry_gate_locks
|
from app.services.trade_policy import MANUAL_BOOK, SHADOW_BOOK, get_reentry_gate_locks
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
|
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
|
||||||
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
|
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
|
||||||
@@ -690,6 +693,66 @@ def build_equity_curve(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
KEY_PERFORMANCE_START = "performance_start_date"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_performance_start(db: AsyncSession) -> date | None:
|
||||||
|
"""Date the performance view starts from, or None for 'all history'.
|
||||||
|
|
||||||
|
The strategy has been revised repeatedly, so early trades were taken under
|
||||||
|
rules that no longer exist. Pinning a start date keeps the comparison inside
|
||||||
|
one regime instead of averaging across configurations that were replaced.
|
||||||
|
"""
|
||||||
|
raw = await settings_store.get_value(db, KEY_PERFORMANCE_START, "")
|
||||||
|
if not raw or not str(raw).strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(raw).strip())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("invalid %s: %r", KEY_PERFORMANCE_START, raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def trade_r_multiple(trade, mark: float | None) -> float | None:
|
||||||
|
"""Result in R — profit measured in units of the trade's own initial risk.
|
||||||
|
|
||||||
|
R is the only sizing-independent yardstick available here: the shadow book
|
||||||
|
sizes at a fixed 1% of equity while manual trades were sized by hand, so
|
||||||
|
currency P&L cannot compare them. Open trades are marked to ``mark``.
|
||||||
|
"""
|
||||||
|
risk_per_share = abs(trade.entry_price - trade.stop_loss)
|
||||||
|
if risk_per_share <= 0:
|
||||||
|
return None
|
||||||
|
exit_price = trade.close_price if trade.status == "closed" else mark
|
||||||
|
if exit_price is None:
|
||||||
|
return None
|
||||||
|
per_share = (
|
||||||
|
exit_price - trade.entry_price
|
||||||
|
if trade.direction == "long"
|
||||||
|
else trade.entry_price - exit_price
|
||||||
|
)
|
||||||
|
return per_share / risk_per_share
|
||||||
|
|
||||||
|
|
||||||
|
def book_stats(trades: list, marks: dict[int, float]) -> dict:
|
||||||
|
"""Sizing-independent summary of one book: counts, win rate, R-multiples."""
|
||||||
|
rs = [
|
||||||
|
r
|
||||||
|
for r in (trade_r_multiple(t, marks.get(t.ticker_id)) for t in trades)
|
||||||
|
if r is not None
|
||||||
|
]
|
||||||
|
closed = [t for t in trades if t.status == "closed"]
|
||||||
|
wins = [r for r in rs if r > 0]
|
||||||
|
return {
|
||||||
|
"trades": len(trades),
|
||||||
|
"closed": len(closed),
|
||||||
|
"open": len(trades) - len(closed),
|
||||||
|
"win_rate": round(100.0 * len(wins) / len(rs), 1) if rs else None,
|
||||||
|
"total_r": round(sum(rs), 2) if rs else 0.0,
|
||||||
|
"avg_r": round(sum(rs) / len(rs), 3) if rs else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
|
async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
|
||||||
"""Equity-curve series for a user's paper book (empty without benchmark data)."""
|
"""Equity-curve series for a user's paper book (empty without benchmark data)."""
|
||||||
trades = (
|
trades = (
|
||||||
@@ -714,3 +777,112 @@ async def equity_curve(db: AsyncSession, user_id: int) -> list[dict]:
|
|||||||
for tid, day, close in rows.all():
|
for tid, day, close in rows.all():
|
||||||
ticker_closes.setdefault(tid, {})[day] = float(close)
|
ticker_closes.setdefault(tid, {})[day] = float(close)
|
||||||
return build_equity_curve(list(trades), ticker_closes, benchmark_closes)
|
return build_equity_curve(list(trades), ticker_closes, benchmark_closes)
|
||||||
|
|
||||||
|
|
||||||
|
def _cumulative_pnl(trades: list, ticker_closes: dict, days: list[date]) -> list[float]:
|
||||||
|
"""Cumulative realized + mark-to-market P&L of one book on each day."""
|
||||||
|
sorted_dates = {tid: sorted(c) for tid, c in ticker_closes.items()}
|
||||||
|
out: list[float] = []
|
||||||
|
for d in days:
|
||||||
|
total = 0.0
|
||||||
|
for t in trades:
|
||||||
|
if t.opened_at.date() > d:
|
||||||
|
continue
|
||||||
|
closed_on = (
|
||||||
|
t.closed_at.date()
|
||||||
|
if (t.status == "closed" and t.closed_at is not None)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if closed_on is not None and closed_on <= d and t.close_price is not None:
|
||||||
|
ref = float(t.close_price)
|
||||||
|
else:
|
||||||
|
ref = _value_on_or_before(
|
||||||
|
sorted_dates.get(t.ticker_id) or [],
|
||||||
|
ticker_closes.get(t.ticker_id) or {},
|
||||||
|
d,
|
||||||
|
)
|
||||||
|
if ref is None:
|
||||||
|
continue
|
||||||
|
per_share = (
|
||||||
|
ref - t.entry_price if t.direction == "long" else t.entry_price - ref
|
||||||
|
)
|
||||||
|
total += per_share * t.shares
|
||||||
|
out.append(round(total, 2))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def performance_summary(db: AsyncSession) -> dict:
|
||||||
|
"""Shadow book vs discretionary book vs SPY, from the configured start date.
|
||||||
|
|
||||||
|
Currency P&L is reported per book but is *not* the comparison — the books
|
||||||
|
size differently, so the honest read is the R-multiple stats. SPY is a plain
|
||||||
|
buy-and-hold reference over the same window rather than a per-trade
|
||||||
|
counterfactual, so one line serves both books.
|
||||||
|
"""
|
||||||
|
start = await get_performance_start(db)
|
||||||
|
stmt = select(PaperTrade)
|
||||||
|
if start is not None:
|
||||||
|
stmt = stmt.where(func.date(PaperTrade.opened_at) >= start)
|
||||||
|
trades = list((await db.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
benchmark_closes = await benchmark_service.load_benchmark_closes(db)
|
||||||
|
empty = {
|
||||||
|
"start_date": start.isoformat() if start else None,
|
||||||
|
"series": [],
|
||||||
|
"stats": {},
|
||||||
|
}
|
||||||
|
if not trades or not benchmark_closes:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
first = min(t.opened_at.date() for t in trades)
|
||||||
|
if start is not None:
|
||||||
|
first = max(first, start)
|
||||||
|
days = [d for d in sorted(benchmark_closes) if d >= first]
|
||||||
|
if not days:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
ticker_ids = {t.ticker_id for t in trades}
|
||||||
|
rows = await db.execute(
|
||||||
|
select(OHLCVRecord.ticker_id, OHLCVRecord.date, OHLCVRecord.close).where(
|
||||||
|
OHLCVRecord.ticker_id.in_(ticker_ids), OHLCVRecord.date >= first
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ticker_closes: dict[int, dict[date, float]] = {}
|
||||||
|
for tid, day, close in rows.all():
|
||||||
|
ticker_closes.setdefault(tid, {})[day] = float(close)
|
||||||
|
|
||||||
|
books = {
|
||||||
|
MANUAL_BOOK: [t for t in trades if (t.book or MANUAL_BOOK) == MANUAL_BOOK],
|
||||||
|
SHADOW_BOOK: [t for t in trades if t.book == SHADOW_BOOK],
|
||||||
|
}
|
||||||
|
pnl = {
|
||||||
|
name: _cumulative_pnl(book_trades, ticker_closes, days)
|
||||||
|
for name, book_trades in books.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
bench_dates = sorted(benchmark_closes)
|
||||||
|
spy0 = _value_on_or_before(bench_dates, benchmark_closes, days[0])
|
||||||
|
spy_pct = [
|
||||||
|
round(100.0 * (benchmark_closes[d] / spy0 - 1.0), 2) if spy0 else 0.0
|
||||||
|
for d in days
|
||||||
|
]
|
||||||
|
|
||||||
|
# Latest close per ticker, for marking open positions in the R stats.
|
||||||
|
marks = {
|
||||||
|
tid: closes[max(closes)] for tid, closes in ticker_closes.items() if closes
|
||||||
|
}
|
||||||
|
stats = {name: book_stats(bt, marks) for name, bt in books.items()}
|
||||||
|
for name in books:
|
||||||
|
stats[name]["pnl"] = pnl[name][-1] if pnl[name] else 0.0
|
||||||
|
stats["spy"] = {"pct": spy_pct[-1] if spy_pct else 0.0}
|
||||||
|
|
||||||
|
series = [
|
||||||
|
{
|
||||||
|
"date": d.isoformat(),
|
||||||
|
"manual_pnl": pnl[MANUAL_BOOK][i],
|
||||||
|
"shadow_pnl": pnl[SHADOW_BOOK][i],
|
||||||
|
"spy_pct": spy_pct[i],
|
||||||
|
}
|
||||||
|
for i, d in enumerate(days)
|
||||||
|
]
|
||||||
|
return {"start_date": start.isoformat() if start else None, "series": series, "stats": stats}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Shadow book — the validated strategy, traded automatically.
|
||||||
|
|
||||||
|
The discretionary paper book only ever contains trades the user chose to take,
|
||||||
|
inside a ~20 minute window, on days they were available. The backtest that
|
||||||
|
validated this strategy does none of that: it takes the top-ranked qualified
|
||||||
|
setups up to capacity, every session, with no human involved. That difference
|
||||||
|
makes the manual book unusable as out-of-sample evidence — it measures the
|
||||||
|
strategy *plus* discretion and availability.
|
||||||
|
|
||||||
|
The shadow book closes that gap. It mirrors ``_simulate_portfolio``'s selection
|
||||||
|
rule exactly and shares the manual book's exit policy, so the only difference
|
||||||
|
between the two books is *which* qualified setups get taken.
|
||||||
|
|
||||||
|
Parity is the load-bearing property here. Selection ordering comes from the
|
||||||
|
stored ``strategy_rank`` the scanner already wrote (the same 80/20
|
||||||
|
momentum/vol blend the backtest ranks on) rather than being recomputed, so the
|
||||||
|
two cannot drift apart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.models.trade_setup import TradeSetup
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import settings_store
|
||||||
|
from app.services.qualification import setup_qualifies
|
||||||
|
from app.services.trade_policy import SHADOW_BOOK, get_reentry_gate_locks
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KEY_ENABLED = "shadow_book_enabled"
|
||||||
|
KEY_CAPACITY = "shadow_book_capacity"
|
||||||
|
KEY_RISK_PCT = "shadow_book_risk_pct"
|
||||||
|
KEY_START_EQUITY = "shadow_book_start_equity"
|
||||||
|
|
||||||
|
# Matches the validated configuration: 10-position book, 1% fixed-fractional
|
||||||
|
# risk. Start equity is only a sizing base — comparisons are drawn in percent
|
||||||
|
# and R-multiples, never in raw currency.
|
||||||
|
DEFAULT_CAPACITY = 10
|
||||||
|
DEFAULT_RISK_PCT = 1.0
|
||||||
|
DEFAULT_START_EQUITY = 100_000.0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_config(db: AsyncSession) -> dict:
|
||||||
|
"""Shadow book sizing/capacity config, falling back to validated defaults."""
|
||||||
|
raw = await settings_store.get_map(
|
||||||
|
db, [KEY_CAPACITY, KEY_RISK_PCT, KEY_START_EQUITY]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _num(key: str, default: float, *, minimum: float, maximum: float) -> float:
|
||||||
|
try:
|
||||||
|
value = float(raw.get(key) or default)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return max(minimum, min(maximum, value))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"capacity": int(_num(KEY_CAPACITY, DEFAULT_CAPACITY, minimum=1, maximum=100)),
|
||||||
|
"risk_pct": _num(KEY_RISK_PCT, DEFAULT_RISK_PCT, minimum=0.05, maximum=10.0),
|
||||||
|
"start_equity": _num(
|
||||||
|
KEY_START_EQUITY, DEFAULT_START_EQUITY, minimum=1000.0, maximum=1e9
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def is_enabled(db: AsyncSession) -> bool:
|
||||||
|
"""Shadow book writes trades to the live book, so it is opt-in."""
|
||||||
|
value = await settings_store.get_value(db, KEY_ENABLED, "false")
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
async def current_equity(db: AsyncSession, start_equity: float) -> float:
|
||||||
|
"""Start equity plus realized P&L of closed shadow trades.
|
||||||
|
|
||||||
|
Open positions are deliberately excluded: sizing off marked-to-market equity
|
||||||
|
would let an unrealized gain inflate the next position, which is not what the
|
||||||
|
backtest does.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PaperTrade).where(
|
||||||
|
PaperTrade.book == SHADOW_BOOK,
|
||||||
|
PaperTrade.status == "closed",
|
||||||
|
PaperTrade.close_price.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
realized = 0.0
|
||||||
|
for trade in result.scalars():
|
||||||
|
per_share = (
|
||||||
|
trade.close_price - trade.entry_price
|
||||||
|
if trade.direction == "long"
|
||||||
|
else trade.entry_price - trade.close_price
|
||||||
|
)
|
||||||
|
realized += per_share * trade.shares
|
||||||
|
return start_equity + realized
|
||||||
|
|
||||||
|
|
||||||
|
def position_shares(equity: float, risk_pct: float, entry: float, stop: float) -> float:
|
||||||
|
"""Fixed-fractional sizing: risk ``risk_pct`` of equity down to the stop."""
|
||||||
|
risk_per_share = abs(entry - stop)
|
||||||
|
if risk_per_share <= 0 or equity <= 0:
|
||||||
|
return 0.0
|
||||||
|
return (equity * risk_pct / 100.0) / risk_per_share
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_ticker_ids(db: AsyncSession) -> set[int]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PaperTrade.ticker_id).where(
|
||||||
|
PaperTrade.book == SHADOW_BOOK, PaperTrade.status == "open"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
|
||||||
|
async def _shadow_user_id(db: AsyncSession) -> int | None:
|
||||||
|
"""Shadow trades are not owned by a person; attach them to the first user."""
|
||||||
|
result = await db.execute(select(User.id).order_by(User.id.asc()).limit(1))
|
||||||
|
row = result.first()
|
||||||
|
return int(row[0]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _todays_qualified_setups(db: AsyncSession, config: dict) -> list[TradeSetup]:
|
||||||
|
"""Latest setup per ticker from the most recent scan, gate-qualified.
|
||||||
|
|
||||||
|
Ordered by ``strategy_rank`` descending — the ordering the backtest selects
|
||||||
|
on. Setups without a rank sort last; they cannot be compared to ranked ones.
|
||||||
|
"""
|
||||||
|
latest_scan = await db.execute(select(func.max(TradeSetup.detected_at)))
|
||||||
|
newest = latest_scan.scalar()
|
||||||
|
if newest is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Everything written by the same scan run (same calendar day, NY-agnostic:
|
||||||
|
# one qualifying scan per day is a hard invariant of the schedule).
|
||||||
|
result = await db.execute(
|
||||||
|
select(TradeSetup).where(
|
||||||
|
func.date(TradeSetup.detected_at) == func.date(newest),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setups = [s for s in result.scalars() if setup_qualifies(s, config)]
|
||||||
|
setups.sort(
|
||||||
|
key=lambda s: (
|
||||||
|
s.strategy_rank if s.strategy_rank is not None else float("-inf")
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return setups
|
||||||
|
|
||||||
|
|
||||||
|
async def open_shadow_positions(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
activation_config: dict,
|
||||||
|
opened_at: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Fill free capacity with the top-ranked qualified setups.
|
||||||
|
|
||||||
|
Mirrors the backtest: rank the qualified cross-section, walk it top-down,
|
||||||
|
skip anything already held or locked out by post-stop gate-reset, and stop
|
||||||
|
at capacity. Returns a summary for the job log.
|
||||||
|
"""
|
||||||
|
summary = {"opened": 0, "skipped_held": 0, "skipped_locked": 0, "symbols": []}
|
||||||
|
config = await get_config(db)
|
||||||
|
|
||||||
|
held = await _open_ticker_ids(db)
|
||||||
|
free_slots = config["capacity"] - len(held)
|
||||||
|
if free_slots <= 0:
|
||||||
|
return summary
|
||||||
|
|
||||||
|
user_id = await _shadow_user_id(db)
|
||||||
|
if user_id is None:
|
||||||
|
logger.warning("shadow book skipped: no user to attach trades to")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
locks = await get_reentry_gate_locks(db, book=SHADOW_BOOK)
|
||||||
|
equity = await current_equity(db, config["start_equity"])
|
||||||
|
timestamp = opened_at or datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
for setup in await _todays_qualified_setups(db, activation_config):
|
||||||
|
if free_slots <= 0:
|
||||||
|
break
|
||||||
|
if setup.ticker_id in held:
|
||||||
|
summary["skipped_held"] += 1
|
||||||
|
continue
|
||||||
|
if setup.ticker_id in locks:
|
||||||
|
summary["skipped_locked"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = float(setup.entry_price or 0.0)
|
||||||
|
stop = float(setup.stop_loss or 0.0)
|
||||||
|
shares = position_shares(equity, config["risk_pct"], entry, stop)
|
||||||
|
if shares <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
PaperTrade(
|
||||||
|
user_id=user_id,
|
||||||
|
ticker_id=setup.ticker_id,
|
||||||
|
direction=setup.direction,
|
||||||
|
entry_price=entry,
|
||||||
|
shares=shares,
|
||||||
|
stop_loss=stop,
|
||||||
|
target=float(setup.target or 0.0),
|
||||||
|
status="open",
|
||||||
|
opened_at=timestamp,
|
||||||
|
fill_mode="near_close",
|
||||||
|
book=SHADOW_BOOK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
held.add(setup.ticker_id)
|
||||||
|
free_slots -= 1
|
||||||
|
summary["opened"] += 1
|
||||||
|
summary["symbols"].append(setup.ticker_id)
|
||||||
|
|
||||||
|
if summary["opened"]:
|
||||||
|
await db.commit()
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
async def symbols_for(db: AsyncSession, ticker_ids: list[int]) -> list[str]:
|
||||||
|
"""Resolve ticker ids to symbols for logging."""
|
||||||
|
if not ticker_ids:
|
||||||
|
return []
|
||||||
|
result = await db.execute(select(Ticker.symbol).where(Ticker.id.in_(ticker_ids)))
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
@@ -22,12 +22,22 @@ def _ny_trading_date(moment: datetime) -> date:
|
|||||||
return moment.astimezone(_REENTRY_DAY_TZ).date()
|
return moment.astimezone(_REENTRY_DAY_TZ).date()
|
||||||
|
|
||||||
|
|
||||||
|
MANUAL_BOOK = "manual"
|
||||||
|
SHADOW_BOOK = "shadow"
|
||||||
|
|
||||||
|
|
||||||
async def _latest_initial_stop_trades(
|
async def _latest_initial_stop_trades(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
closed_before: datetime | None = None,
|
closed_before: datetime | None = None,
|
||||||
|
book: str = MANUAL_BOOK,
|
||||||
) -> dict[int, PaperTrade]:
|
) -> dict[int, PaperTrade]:
|
||||||
"""Return a ticker's latest closed trade only when it was an initial stop."""
|
"""Return a ticker's latest closed trade only when it was an initial stop.
|
||||||
|
|
||||||
|
Scoped to one ``book``: the discretionary and shadow books diverge as soon
|
||||||
|
as their entries differ, so each must see only its own stop history when
|
||||||
|
deciding whether a ticker is locked out of re-entry.
|
||||||
|
"""
|
||||||
ranked_stmt = (
|
ranked_stmt = (
|
||||||
select(
|
select(
|
||||||
PaperTrade.id.label("trade_id"),
|
PaperTrade.id.label("trade_id"),
|
||||||
@@ -41,6 +51,7 @@ async def _latest_initial_stop_trades(
|
|||||||
.where(
|
.where(
|
||||||
PaperTrade.status == "closed",
|
PaperTrade.status == "closed",
|
||||||
PaperTrade.closed_at.is_not(None),
|
PaperTrade.closed_at.is_not(None),
|
||||||
|
PaperTrade.book == book,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if closed_before is not None:
|
if closed_before is not None:
|
||||||
@@ -58,7 +69,9 @@ async def _latest_initial_stop_trades(
|
|||||||
return {trade.ticker_id: trade for trade in result.scalars()}
|
return {trade.ticker_id: trade for trade in result.scalars()}
|
||||||
|
|
||||||
|
|
||||||
async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]:
|
async def get_reentry_gate_locks(
|
||||||
|
db: AsyncSession, *, book: str = MANUAL_BOOK
|
||||||
|
) -> dict[int, datetime]:
|
||||||
"""Return tickers still waiting for a post-stop gate failure.
|
"""Return tickers still waiting for a post-stop gate failure.
|
||||||
|
|
||||||
A later qualified setup is actionable only after the daily scanner has
|
A later qualified setup is actionable only after the daily scanner has
|
||||||
@@ -66,7 +79,7 @@ async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]:
|
|||||||
then a fresh qualification. The returned timestamp is the stop time and is
|
then a fresh qualification. The returned timestamp is the stop time and is
|
||||||
useful for diagnostics; callers normally only need the keys.
|
useful for diagnostics; callers normally only need the keys.
|
||||||
"""
|
"""
|
||||||
latest = await _latest_initial_stop_trades(db)
|
latest = await _latest_initial_stop_trades(db, book=book)
|
||||||
return {
|
return {
|
||||||
ticker_id: trade.closed_at
|
ticker_id: trade.closed_at
|
||||||
for ticker_id, trade in latest.items()
|
for ticker_id, trade in latest.items()
|
||||||
@@ -80,6 +93,7 @@ async def observe_reentry_gate_transitions(
|
|||||||
evaluated_ticker_ids: Iterable[int],
|
evaluated_ticker_ids: Iterable[int],
|
||||||
qualified_ticker_ids: Iterable[int],
|
qualified_ticker_ids: Iterable[int],
|
||||||
observed_at: datetime | None = None,
|
observed_at: datetime | None = None,
|
||||||
|
book: str = MANUAL_BOOK,
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
"""Persist gate-failure and later requalification observations.
|
"""Persist gate-failure and later requalification observations.
|
||||||
|
|
||||||
@@ -93,7 +107,7 @@ async def observe_reentry_gate_transitions(
|
|||||||
return set()
|
return set()
|
||||||
qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids}
|
qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids}
|
||||||
timestamp = observed_at or datetime.now(timezone.utc)
|
timestamp = observed_at or datetime.now(timezone.utc)
|
||||||
latest = await _latest_initial_stop_trades(db, closed_before=timestamp)
|
latest = await _latest_initial_stop_trades(db, closed_before=timestamp, book=book)
|
||||||
updated: set[int] = set()
|
updated: set[int] = set()
|
||||||
for ticker_id in evaluated:
|
for ticker_id in evaluated:
|
||||||
trade = latest.get(ticker_id)
|
trade = latest.get(ticker_id)
|
||||||
|
|||||||
@@ -92,6 +92,41 @@ export function updateScheduleSettings(payload: Partial<ScheduleConfig>) {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PerformanceConfig {
|
||||||
|
start_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPerformanceSettings() {
|
||||||
|
return apiClient
|
||||||
|
.get<PerformanceConfig>('admin/settings/performance')
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePerformanceSettings(payload: Partial<PerformanceConfig>) {
|
||||||
|
return apiClient
|
||||||
|
.put<PerformanceConfig>('admin/settings/performance', payload)
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShadowBookConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
capacity: number;
|
||||||
|
risk_pct: number;
|
||||||
|
start_equity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShadowBookSettings() {
|
||||||
|
return apiClient
|
||||||
|
.get<ShadowBookConfig>('admin/settings/shadow-book')
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateShadowBookSettings(payload: Partial<ShadowBookConfig>) {
|
||||||
|
return apiClient
|
||||||
|
.put<ShadowBookConfig>('admin/settings/shadow-book', payload)
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
export function getSentimentSettings() {
|
export function getSentimentSettings() {
|
||||||
return apiClient
|
return apiClient
|
||||||
.get<SentimentProviderConfig>('admin/settings/sentiment')
|
.get<SentimentProviderConfig>('admin/settings/sentiment')
|
||||||
|
|||||||
@@ -38,6 +38,39 @@ export function getEquityCurve() {
|
|||||||
return apiClient.get<EquityPoint[]>('paper-trades/equity-curve').then((r) => r.data);
|
return apiClient.get<EquityPoint[]>('paper-trades/equity-curve').then((r) => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PerfPoint {
|
||||||
|
date: string;
|
||||||
|
manual_pnl: number;
|
||||||
|
shadow_pnl: number;
|
||||||
|
spy_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookStats {
|
||||||
|
trades: number;
|
||||||
|
closed: number;
|
||||||
|
open: number;
|
||||||
|
win_rate: number | null;
|
||||||
|
total_r: number;
|
||||||
|
avg_r: number | null;
|
||||||
|
pnl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceSummary {
|
||||||
|
start_date: string | null;
|
||||||
|
series: PerfPoint[];
|
||||||
|
stats: {
|
||||||
|
manual?: BookStats;
|
||||||
|
shadow?: BookStats;
|
||||||
|
spy?: { pct: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPerformance() {
|
||||||
|
return apiClient
|
||||||
|
.get<PerformanceSummary>('paper-trades/performance')
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
export function closePaperTrade(id: number, closePrice?: number) {
|
export function closePaperTrade(id: number, closePrice?: number) {
|
||||||
return apiClient
|
return apiClient
|
||||||
.post<{ id: number; status: string }>(`paper-trades/${id}/close`, {
|
.post<{ id: number; status: string }>(`paper-trades/${id}/close`, {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getPerformanceSettings,
|
||||||
|
getShadowBookSettings,
|
||||||
|
updatePerformanceSettings,
|
||||||
|
updateShadowBookSettings,
|
||||||
|
type ShadowBookConfig,
|
||||||
|
} from '../../api/admin';
|
||||||
|
import { SkeletonCard } from '../ui/Skeleton';
|
||||||
|
|
||||||
|
/** Performance window + the auto-traded shadow book.
|
||||||
|
*
|
||||||
|
* These belong together: the shadow book is what the comparison measures, and
|
||||||
|
* the start date is what keeps the comparison inside a single strategy
|
||||||
|
* configuration.
|
||||||
|
*/
|
||||||
|
export function PerformanceSettings() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const window = useQuery({ queryKey: ['admin', 'performance'], queryFn: getPerformanceSettings });
|
||||||
|
const shadow = useQuery({ queryKey: ['admin', 'shadow-book'], queryFn: getShadowBookSettings });
|
||||||
|
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [book, setBook] = useState<ShadowBookConfig | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.data) setStartDate(window.data.start_date ?? '');
|
||||||
|
}, [window.data]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (shadow.data) setBook(shadow.data);
|
||||||
|
}, [shadow.data]);
|
||||||
|
|
||||||
|
const saveWindow = useMutation({
|
||||||
|
mutationFn: () => updatePerformanceSettings({ start_date: startDate }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'performance'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBook = useMutation({
|
||||||
|
mutationFn: (payload: Partial<ShadowBookConfig>) => updateShadowBookSettings(payload),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setBook(data);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'shadow-book'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.isLoading || shadow.isLoading || !book) return <SkeletonCard />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass space-y-5 p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-200">Performance & Shadow Book</h3>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-500">
|
||||||
|
The <span className="text-gray-300">shadow book</span> trades the validated strategy with no
|
||||||
|
human input: top-ranked qualified setups up to capacity, sized to a fixed risk, entered right
|
||||||
|
after the near-close scan. It shares the paper exit policy with your own trades, so the only
|
||||||
|
difference between the two books is <span className="text-gray-300">which setups get taken</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-gray-400">Performance since</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="input-glass w-48 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveWindow.mutate()}
|
||||||
|
disabled={saveWindow.isPending}
|
||||||
|
className="btn-glass px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{saveWindow.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{startDate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStartDate('');
|
||||||
|
updatePerformanceSettings({ start_date: '' }).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin', 'performance'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['paper-trades', 'performance'] });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="btn-glass px-3 py-2 text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="block text-[11px] leading-relaxed text-gray-500">
|
||||||
|
Trades opened before this date are excluded from the Performance card. The strategy has been
|
||||||
|
revised repeatedly — pinning a start keeps the comparison inside one configuration instead of
|
||||||
|
averaging across rules that no longer exist. Empty shows all history.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="border-t border-white/5 pt-4">
|
||||||
|
<label className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={book.enabled}
|
||||||
|
onChange={(e) => saveBook.mutate({ enabled: e.target.checked })}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="text-sm text-gray-200">Shadow book enabled</span>
|
||||||
|
<span className="block text-[11px] leading-relaxed text-gray-500">
|
||||||
|
Starts opening real paper positions automatically on the next near-close scan. Verify its
|
||||||
|
first selections match a backtest of that day's cross-section before trusting the curve.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-gray-400">Capacity (positions)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={book.capacity}
|
||||||
|
onChange={(e) => setBook({ ...book, capacity: Number(e.target.value) })}
|
||||||
|
onBlur={() => saveBook.mutate({ capacity: book.capacity })}
|
||||||
|
className="input-glass w-full px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-gray-400">Risk per trade (%)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.05"
|
||||||
|
min={0.05}
|
||||||
|
max={10}
|
||||||
|
value={book.risk_pct}
|
||||||
|
onChange={(e) => setBook({ ...book, risk_pct: Number(e.target.value) })}
|
||||||
|
onBlur={() => saveBook.mutate({ risk_pct: book.risk_pct })}
|
||||||
|
className="input-glass w-full px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-gray-400">Start equity ($)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1000}
|
||||||
|
step={1000}
|
||||||
|
value={book.start_equity}
|
||||||
|
onChange={(e) => setBook({ ...book, start_equity: Number(e.target.value) })}
|
||||||
|
onBlur={() => saveBook.mutate({ start_equity: book.start_equity })}
|
||||||
|
className="input-glass w-full px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] leading-relaxed text-gray-500">
|
||||||
|
Defaults match the validated configuration: 10 positions, 1% fixed-fractional risk. Start
|
||||||
|
equity is only a sizing base — the books are compared in R-multiples, not currency.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getEquityCurve } from '../../api/paperTrades';
|
import { getPerformance, type BookStats } from '../../api/paperTrades';
|
||||||
import { Section } from '../ui/Section';
|
import { Section } from '../ui/Section';
|
||||||
|
|
||||||
const W = 760;
|
const W = 1040;
|
||||||
const H = 220;
|
const H = 260;
|
||||||
const PAD = { top: 14, right: 84, bottom: 26, left: 56 };
|
const PAD = { top: 16, right: 92, bottom: 28, left: 60 };
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
shadow: 'var(--up)',
|
||||||
|
manual: 'var(--accent, #7aa2f7)',
|
||||||
|
spy: 'var(--ink-3)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
function money(v: number): string {
|
function money(v: number): string {
|
||||||
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
|
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
|
||||||
@@ -25,17 +31,54 @@ function niceTicks(lo: number, hi: number, count = 4): number[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paper book vs the same dollars riding SPY — cumulative P&L since first trade. */
|
/** R-multiple stats read straight across; currency P&L does not, because the
|
||||||
|
* books size differently. Kept adjacent so the comparison is hard to misread. */
|
||||||
|
function StatCell({ label, stats, color }: { label: string; stats?: BookStats; color: string }) {
|
||||||
|
if (!stats || stats.trades === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-[7rem]">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||||
|
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="num mt-1 text-sm text-gray-500">no trades yet</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="min-w-[7rem]">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||||
|
<i className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="num mt-1 text-lg font-semibold text-gray-100">
|
||||||
|
{stats.total_r > 0 ? '+' : ''}
|
||||||
|
{stats.total_r.toFixed(2)}R
|
||||||
|
</div>
|
||||||
|
<div className="num text-[11px] leading-relaxed text-gray-500">
|
||||||
|
{stats.trades} trades · {stats.win_rate ?? '—'}% win
|
||||||
|
<br />
|
||||||
|
avg {stats.avg_r === null ? '—' : `${stats.avg_r > 0 ? '+' : ''}${stats.avg_r.toFixed(2)}R`} · {money(stats.pnl)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shadow book (the strategy, traded automatically) vs the discretionary book
|
||||||
|
* vs SPY. Both books share an exit policy, so the only difference is which
|
||||||
|
* qualified setups get taken. */
|
||||||
export function PerfChart() {
|
export function PerfChart() {
|
||||||
const curve = useQuery({ queryKey: ['paper-trades', 'equity-curve'], queryFn: getEquityCurve });
|
const perf = useQuery({ queryKey: ['paper-trades', 'performance'], queryFn: getPerformance });
|
||||||
const [hover, setHover] = useState<number | null>(null);
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
const data = curve.data ?? [];
|
const data = perf.data?.series ?? [];
|
||||||
|
const stats = perf.data?.stats ?? {};
|
||||||
|
const startDate = perf.data?.start_date ?? null;
|
||||||
|
|
||||||
const geom = useMemo(() => {
|
const geom = useMemo(() => {
|
||||||
if (data.length < 2) return null;
|
if (data.length < 2) return null;
|
||||||
const values = data.flatMap((p) => [p.book_pnl, p.benchmark_pnl, 0]);
|
const values = data.flatMap((p) => [p.manual_pnl, p.shadow_pnl, 0]);
|
||||||
const lo = Math.min(...values);
|
const lo = Math.min(...values);
|
||||||
const hi = Math.max(...values);
|
const hi = Math.max(...values);
|
||||||
const pad = (hi - lo) * 0.08 || 1;
|
const pad = (hi - lo) * 0.08 || 1;
|
||||||
@@ -45,9 +88,20 @@ export function PerfChart() {
|
|||||||
const plotH = H - PAD.top - PAD.bottom;
|
const plotH = H - PAD.top - PAD.bottom;
|
||||||
const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW;
|
const px = (i: number) => PAD.left + (i / (data.length - 1)) * plotW;
|
||||||
const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH;
|
const py = (v: number) => PAD.top + plotH - ((v - yLo) / (yHi - yLo)) * plotH;
|
||||||
const line = (key: 'book_pnl' | 'benchmark_pnl') =>
|
const line = (key: 'manual_pnl' | 'shadow_pnl') =>
|
||||||
data.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' ');
|
data.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(p[key]).toFixed(1)}`).join(' ');
|
||||||
// Month boundaries for the x axis.
|
|
||||||
|
// SPY is a percentage reference, so it rides its own scale pinned to the
|
||||||
|
// same zero line — otherwise a flat book would squash it out of view.
|
||||||
|
const spyLo = Math.min(...data.map((p) => p.spy_pct), 0);
|
||||||
|
const spyHi = Math.max(...data.map((p) => p.spy_pct), 0);
|
||||||
|
const spySpan = Math.max(Math.abs(spyLo), Math.abs(spyHi)) || 1;
|
||||||
|
const bookSpan = Math.max(Math.abs(yLo), Math.abs(yHi)) || 1;
|
||||||
|
const spyY = (v: number) => py((v / spySpan) * bookSpan);
|
||||||
|
const spyLine = data
|
||||||
|
.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${spyY(p.spy_pct).toFixed(1)}`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
const xTicks: { i: number; label: string }[] = [];
|
const xTicks: { i: number; label: string }[] = [];
|
||||||
let lastMonth = '';
|
let lastMonth = '';
|
||||||
data.forEach((p, i) => {
|
data.forEach((p, i) => {
|
||||||
@@ -57,15 +111,24 @@ export function PerfChart() {
|
|||||||
xTicks.push({ i, label: new Date(`${p.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' }) });
|
xTicks.push({ i, label: new Date(`${p.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' }) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (xTicks.length > 8) {
|
if (xTicks.length > 10) {
|
||||||
const keep = Math.ceil(xTicks.length / 8);
|
const keep = Math.ceil(xTicks.length / 10);
|
||||||
for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1);
|
for (let k = xTicks.length - 1; k >= 0; k--) if (k % keep !== 0) xTicks.splice(k, 1);
|
||||||
}
|
}
|
||||||
return { yLo, yHi, plotH, px, py, line, yTicks: niceTicks(yLo, yHi), xTicks };
|
return { yLo, yHi, plotH, px, py, line, spyLine, spyY, yTicks: niceTicks(yLo, yHi), xTicks };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
if (!geom) return null;
|
if (!geom) {
|
||||||
const { px, py, line, yTicks, xTicks, plotH } = geom;
|
return (
|
||||||
|
<Section title="Performance" hint="shadow book vs your picks vs SPY">
|
||||||
|
<div className="glass p-5 text-sm text-gray-500">
|
||||||
|
No trades in the selected window yet
|
||||||
|
{startDate ? ` (since ${startDate})` : ''}. The shadow book starts recording once enabled.
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { px, py, line, spyLine, spyY, yTicks, xTicks, plotH } = geom;
|
||||||
|
|
||||||
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
const rect = svgRef.current?.getBoundingClientRect();
|
const rect = svgRef.current?.getBoundingClientRect();
|
||||||
@@ -81,11 +144,32 @@ export function PerfChart() {
|
|||||||
new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Performance" hint="paper book vs the same dollars in SPY · cumulative P&L">
|
<Section
|
||||||
|
title="Performance"
|
||||||
|
hint={`shadow book vs your picks vs SPY${startDate ? ` · since ${startDate}` : ''}`}
|
||||||
|
>
|
||||||
<div className="glass p-5 pb-2">
|
<div className="glass p-5 pb-2">
|
||||||
<div className="flex justify-end gap-4 text-xs text-gray-400">
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-8 gap-y-3">
|
||||||
<span><i className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle" style={{ background: 'var(--up)' }} /> Book</span>
|
<div className="flex flex-wrap gap-x-8 gap-y-3">
|
||||||
<span><i className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle" style={{ background: 'var(--ink-3)' }} /> Same $ in SPY</span>
|
<StatCell label="Shadow (strategy)" stats={stats.shadow} color={COLORS.shadow} />
|
||||||
|
<StatCell label="Your picks" stats={stats.manual} color={COLORS.manual} />
|
||||||
|
<div className="min-w-[7rem]">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||||
|
<i className="inline-block h-2 w-2 rounded-full" style={{ background: COLORS.spy }} />
|
||||||
|
SPY
|
||||||
|
</div>
|
||||||
|
<div className="num mt-1 text-lg font-semibold text-gray-300">
|
||||||
|
{(stats.spy?.pct ?? 0) > 0 ? '+' : ''}
|
||||||
|
{(stats.spy?.pct ?? 0).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div className="num text-[11px] text-gray-500">buy & hold</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-[22rem] text-[11px] leading-relaxed text-gray-500">
|
||||||
|
Books share the same exit, so the difference is <b className="text-gray-400">selection</b>.
|
||||||
|
Compare on <b className="text-gray-400">R</b>, not $ — sizing differs. Expect months of
|
||||||
|
noise before a gap means anything.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
@@ -94,7 +178,9 @@ export function PerfChart() {
|
|||||||
onMouseMove={onMove}
|
onMouseMove={onMove}
|
||||||
onMouseLeave={() => setHover(null)}
|
onMouseLeave={() => setHover(null)}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Paper book cumulative P&L ${money(data[last].book_pnl)} versus ${money(data[last].benchmark_pnl)} for the same dollars in SPY`}
|
aria-label={`Shadow book ${money(data[last].shadow_pnl)}, your picks ${money(
|
||||||
|
data[last].manual_pnl,
|
||||||
|
)}, SPY ${(data[last].spy_pct ?? 0).toFixed(1)} percent`}
|
||||||
>
|
>
|
||||||
{yTicks.map((t) => (
|
{yTicks.map((t) => (
|
||||||
<g key={t}>
|
<g key={t}>
|
||||||
@@ -104,35 +190,40 @@ export function PerfChart() {
|
|||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
{/* zero baseline slightly stronger when it's inside the plot */}
|
|
||||||
<line x1={PAD.left} x2={W - PAD.right} y1={py(0)} y2={py(0)} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
|
<line x1={PAD.left} x2={W - PAD.right} y1={py(0)} y2={py(0)} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
|
||||||
{xTicks.map(({ i, label }) => (
|
{xTicks.map(({ i, label }) => (
|
||||||
<text key={`${label}-${i}`} x={px(i)} y={H - 6} textAnchor="middle" className="num" fill="var(--ink-3)" fontSize="10">
|
<text key={`${label}-${i}`} x={px(i)} y={H - 6} textAnchor="middle" className="num" fill="var(--ink-3)" fontSize="10">
|
||||||
{label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
<path d={line('benchmark_pnl')} fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinejoin="round" />
|
<path d={spyLine} fill="none" stroke={COLORS.spy} strokeWidth="1.5" strokeDasharray="4 3" strokeLinejoin="round" />
|
||||||
<path d={line('book_pnl')} fill="none" stroke="var(--up)" strokeWidth="2" strokeLinejoin="round" />
|
<path d={line('manual_pnl')} fill="none" stroke={COLORS.manual} strokeWidth="2" strokeLinejoin="round" />
|
||||||
|
<path d={line('shadow_pnl')} fill="none" stroke={COLORS.shadow} strokeWidth="2" strokeLinejoin="round" />
|
||||||
{hover !== null && (
|
{hover !== null && (
|
||||||
<g>
|
<g>
|
||||||
<line x1={px(hover)} x2={px(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--ink-3)" strokeWidth="1" />
|
<line x1={px(hover)} x2={px(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--ink-3)" strokeWidth="1" />
|
||||||
<circle cx={px(hover)} cy={py(data[hover].book_pnl)} r="4.5" fill="var(--up)" stroke="var(--surface)" strokeWidth="2" />
|
<circle cx={px(hover)} cy={py(data[hover].shadow_pnl)} r="4.5" fill={COLORS.shadow} stroke="var(--surface)" strokeWidth="2" />
|
||||||
<circle cx={px(hover)} cy={py(data[hover].benchmark_pnl)} r="4.5" fill="var(--ink-3)" stroke="var(--surface)" strokeWidth="2" />
|
<circle cx={px(hover)} cy={py(data[hover].manual_pnl)} r="4.5" fill={COLORS.manual} stroke="var(--surface)" strokeWidth="2" />
|
||||||
|
<circle cx={px(hover)} cy={spyY(data[hover].spy_pct)} r="4" fill={COLORS.spy} stroke="var(--surface)" strokeWidth="2" />
|
||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
<circle cx={px(last)} cy={py(data[last].book_pnl)} r="4" fill="var(--up)" stroke="var(--surface)" strokeWidth="2" />
|
<text x={px(last) + 10} y={py(data[last].shadow_pnl) + 4} className="num" fill="var(--ink)" fontSize="11" fontWeight="600">
|
||||||
<circle cx={px(last)} cy={py(data[last].benchmark_pnl)} r="4" fill="var(--ink-3)" stroke="var(--surface)" strokeWidth="2" />
|
{money(data[last].shadow_pnl)}
|
||||||
<text x={px(last) + 10} y={py(data[last].book_pnl) + 4} className="num" fill="var(--ink)" fontSize="11" fontWeight="600">
|
|
||||||
{money(data[last].book_pnl)}
|
|
||||||
</text>
|
</text>
|
||||||
<text x={px(last) + 10} y={py(data[last].benchmark_pnl) + 4} className="num" fill="var(--ink-2)" fontSize="11">
|
<text x={px(last) + 10} y={py(data[last].manual_pnl) + 4} className="num" fill="var(--ink-2)" fontSize="11">
|
||||||
{money(data[last].benchmark_pnl)}
|
{money(data[last].manual_pnl)}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="num px-1 pb-1 pt-1.5 text-[11px] text-gray-500" aria-live="polite">
|
<p className="num px-1 pb-1 pt-1.5 text-[11px] text-gray-500" aria-live="polite">
|
||||||
{hb
|
{hb ? (
|
||||||
? <>{fmtDate(hb.date)} — book <b className="text-gray-200">{money(hb.book_pnl)}</b> · SPY <b className="text-gray-200">{money(hb.benchmark_pnl)}</b></>
|
<>
|
||||||
: <>hover for daily values · realized + mark-to-market, since first paper trade</>}
|
{fmtDate(hb.date)} — shadow <b className="text-gray-200">{money(hb.shadow_pnl)}</b> · yours{' '}
|
||||||
|
<b className="text-gray-200">{money(hb.manual_pnl)}</b> · SPY{' '}
|
||||||
|
<b className="text-gray-200">{hb.spy_pct.toFixed(1)}%</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>hover for daily values · realized + mark-to-market{startDate ? ` · window starts ${startDate}` : ''}</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AlertSettings } from '../components/admin/AlertSettings';
|
|||||||
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
|
||||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||||
import { JobControls } from '../components/admin/JobControls';
|
import { JobControls } from '../components/admin/JobControls';
|
||||||
|
import { PerformanceSettings } from '../components/admin/PerformanceSettings';
|
||||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||||
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
|
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
|
||||||
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
|
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
|
||||||
@@ -36,6 +37,7 @@ export default function AdminPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ActivationSettings />
|
<ActivationSettings />
|
||||||
<ExitPolicySettings />
|
<ExitPolicySettings />
|
||||||
|
<PerformanceSettings />
|
||||||
<AlertSettings />
|
<AlertSettings />
|
||||||
<SentimentProviderSettings />
|
<SentimentProviderSettings />
|
||||||
<TickerUniverseBootstrap />
|
<TickerUniverseBootstrap />
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Performance comparison: per-book series, R-multiples, and the start-date window."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import paper_trade_service as pts
|
||||||
|
from app.services.trade_policy import MANUAL_BOOK, SHADOW_BOOK
|
||||||
|
|
||||||
|
|
||||||
|
def _trade(*, book, entry=100.0, stop=95.0, close=None, shares=10.0, opened_days_ago=5):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return SimpleNamespace(
|
||||||
|
ticker_id=1,
|
||||||
|
direction="long",
|
||||||
|
entry_price=entry,
|
||||||
|
stop_loss=stop,
|
||||||
|
shares=shares,
|
||||||
|
book=book,
|
||||||
|
status="closed" if close is not None else "open",
|
||||||
|
close_price=close,
|
||||||
|
opened_at=now - timedelta(days=opened_days_ago),
|
||||||
|
closed_at=now if close is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRMultiple:
|
||||||
|
def test_winner_measured_in_units_of_initial_risk(self):
|
||||||
|
# Entry 100, stop 95 → 5 of risk. Exit 115 → +15 → +3R.
|
||||||
|
trade = _trade(book=SHADOW_BOOK, close=115.0)
|
||||||
|
assert pts.trade_r_multiple(trade, None) == pytest.approx(3.0)
|
||||||
|
|
||||||
|
def test_full_stop_is_minus_one_r(self):
|
||||||
|
trade = _trade(book=SHADOW_BOOK, close=95.0)
|
||||||
|
assert pts.trade_r_multiple(trade, None) == pytest.approx(-1.0)
|
||||||
|
|
||||||
|
def test_open_trade_marks_to_the_latest_close(self):
|
||||||
|
trade = _trade(book=SHADOW_BOOK)
|
||||||
|
assert pts.trade_r_multiple(trade, 110.0) == pytest.approx(2.0)
|
||||||
|
|
||||||
|
def test_no_risk_distance_has_no_r(self):
|
||||||
|
trade = _trade(book=SHADOW_BOOK, entry=100.0, stop=100.0, close=120.0)
|
||||||
|
assert pts.trade_r_multiple(trade, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookStats:
|
||||||
|
def test_r_is_independent_of_position_size(self):
|
||||||
|
"""The whole point: a 10-share and a 1000-share book compare equally."""
|
||||||
|
small = pts.book_stats([_trade(book=SHADOW_BOOK, close=115.0, shares=10)], {})
|
||||||
|
large = pts.book_stats([_trade(book=MANUAL_BOOK, close=115.0, shares=1000)], {})
|
||||||
|
assert small["total_r"] == large["total_r"] == pytest.approx(3.0)
|
||||||
|
|
||||||
|
def test_counts_and_win_rate(self):
|
||||||
|
trades = [
|
||||||
|
_trade(book=SHADOW_BOOK, close=115.0),
|
||||||
|
_trade(book=SHADOW_BOOK, close=95.0),
|
||||||
|
_trade(book=SHADOW_BOOK),
|
||||||
|
]
|
||||||
|
stats = pts.book_stats(trades, {1: 110.0})
|
||||||
|
assert stats["trades"] == 3
|
||||||
|
assert stats["closed"] == 2
|
||||||
|
assert stats["open"] == 1
|
||||||
|
# +3R, -1R, +2R marked → 2 of 3 positive.
|
||||||
|
assert stats["win_rate"] == pytest.approx(66.7)
|
||||||
|
assert stats["total_r"] == pytest.approx(4.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformanceStartDate:
|
||||||
|
@pytest.fixture
|
||||||
|
async def session(self):
|
||||||
|
from tests.conftest import _test_session_factory
|
||||||
|
|
||||||
|
async with _test_session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unset_means_all_history(self, session):
|
||||||
|
assert await pts.get_performance_start(session) is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reads_an_iso_date(self, session):
|
||||||
|
await pts.settings_store.upsert_setting(
|
||||||
|
session, pts.KEY_PERFORMANCE_START, "2026-07-20"
|
||||||
|
)
|
||||||
|
assert await pts.get_performance_start(session) == date(2026, 7, 20)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_garbage_falls_back_to_all_history(self, session):
|
||||||
|
"""A bad setting must not blank the whole performance card."""
|
||||||
|
await pts.settings_store.upsert_setting(
|
||||||
|
session, pts.KEY_PERFORMANCE_START, "not-a-date"
|
||||||
|
)
|
||||||
|
assert await pts.get_performance_start(session) is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_string_means_all_history(self, session):
|
||||||
|
await pts.settings_store.upsert_setting(session, pts.KEY_PERFORMANCE_START, "")
|
||||||
|
assert await pts.get_performance_start(session) is None
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Shadow book selection, sizing and book isolation.
|
||||||
|
|
||||||
|
The shadow book only has evidentiary value if it selects what the backtest
|
||||||
|
would select: top-ranked qualified setups, up to capacity, skipping held names
|
||||||
|
and post-stop gate-reset lockouts. These tests pin that contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.models.trade_setup import TradeSetup
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import shadow_book_service
|
||||||
|
from app.services.trade_policy import MANUAL_BOOK, SHADOW_BOOK, get_reentry_gate_locks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def session():
|
||||||
|
from tests.conftest import _test_session_factory
|
||||||
|
|
||||||
|
async with _test_session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
# Floors the gate applies; every setup below clears them so tests exercise
|
||||||
|
# ranking rather than qualification.
|
||||||
|
_CONFIG = {
|
||||||
|
"min_rr": 2.0,
|
||||||
|
"min_confidence": 0.0,
|
||||||
|
"min_momentum_percentile": 80.0,
|
||||||
|
"exclude_neutral": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(session, symbols: list[str]) -> dict[str, int]:
|
||||||
|
session.add(User(id=1, username="owner", password_hash="x"))
|
||||||
|
ids: dict[str, int] = {}
|
||||||
|
for i, symbol in enumerate(symbols, start=1):
|
||||||
|
ticker = Ticker(id=i, symbol=symbol, name=symbol)
|
||||||
|
session.add(ticker)
|
||||||
|
ids[symbol] = i
|
||||||
|
await session.commit()
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(ticker_id: int, *, rank: float, detected: datetime, entry=100.0, stop=95.0):
|
||||||
|
target = entry + 3 * (entry - stop)
|
||||||
|
return TradeSetup(
|
||||||
|
ticker_id=ticker_id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=entry,
|
||||||
|
stop_loss=stop,
|
||||||
|
target=target,
|
||||||
|
rr_ratio=3.0,
|
||||||
|
composite_score=70.0,
|
||||||
|
confidence_score=70.0,
|
||||||
|
detected_at=detected,
|
||||||
|
strategy_rank=rank,
|
||||||
|
momentum_percentile=90.0,
|
||||||
|
recommended_action="buy",
|
||||||
|
targets_json=json.dumps(
|
||||||
|
[{"price": target, "probability": 45.0, "is_primary": True, "rr": 3.0}]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSizing:
|
||||||
|
def test_risks_one_percent_down_to_the_stop(self):
|
||||||
|
shares = shadow_book_service.position_shares(100_000, 1.0, 100.0, 95.0)
|
||||||
|
assert shares == pytest.approx(200.0) # $1,000 risk / $5 per share
|
||||||
|
|
||||||
|
def test_zero_risk_distance_takes_no_position(self):
|
||||||
|
assert shadow_book_service.position_shares(100_000, 1.0, 100.0, 100.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelection:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_takes_top_ranked_up_to_capacity(self, session):
|
||||||
|
ids = await _seed(session, ["AAA", "BBB", "CCC"])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
_setup(ids["AAA"], rank=0.10, detected=now),
|
||||||
|
_setup(ids["BBB"], rank=0.90, detected=now),
|
||||||
|
_setup(ids["CCC"], rank=0.50, detected=now),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await shadow_book_service.settings_store.upsert_setting(
|
||||||
|
session, shadow_book_service.KEY_CAPACITY, "2"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = await shadow_book_service.open_shadow_positions(
|
||||||
|
session, activation_config=_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["opened"] == 2
|
||||||
|
# Highest strategy_rank first — the backtest's ordering key.
|
||||||
|
assert summary["symbols"] == [ids["BBB"], ids["CCC"]]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_names_already_held(self, session):
|
||||||
|
ids = await _seed(session, ["AAA", "BBB"])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
session.add_all(
|
||||||
|
[_setup(ids["AAA"], rank=0.9, detected=now), _setup(ids["BBB"], rank=0.5, detected=now)]
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
PaperTrade(
|
||||||
|
user_id=1, ticker_id=ids["AAA"], direction="long", entry_price=100.0,
|
||||||
|
shares=10.0, stop_loss=95.0, target=115.0, status="open",
|
||||||
|
opened_at=now, book=SHADOW_BOOK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
summary = await shadow_book_service.open_shadow_positions(
|
||||||
|
session, activation_config=_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["skipped_held"] == 1
|
||||||
|
assert summary["symbols"] == [ids["BBB"]]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_respects_post_stop_gate_lock(self, session):
|
||||||
|
ids = await _seed(session, ["AAA"])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
session.add(_setup(ids["AAA"], rank=0.9, detected=now))
|
||||||
|
# Stopped out and never requalified — locked out of re-entry.
|
||||||
|
session.add(
|
||||||
|
PaperTrade(
|
||||||
|
user_id=1, ticker_id=ids["AAA"], direction="long", entry_price=100.0,
|
||||||
|
shares=10.0, stop_loss=95.0, target=115.0, status="closed",
|
||||||
|
opened_at=now - timedelta(days=5), closed_at=now - timedelta(days=1),
|
||||||
|
close_price=95.0, close_reason="stop", book=SHADOW_BOOK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
summary = await shadow_book_service.open_shadow_positions(
|
||||||
|
session, activation_config=_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["opened"] == 0
|
||||||
|
assert summary["skipped_locked"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookIsolation:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gate_locks_do_not_leak_between_books(self, session):
|
||||||
|
"""A manual stop must not lock the shadow book out of the same name."""
|
||||||
|
ids = await _seed(session, ["AAA"])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
session.add(
|
||||||
|
PaperTrade(
|
||||||
|
user_id=1, ticker_id=ids["AAA"], direction="long", entry_price=100.0,
|
||||||
|
shares=10.0, stop_loss=95.0, target=115.0, status="closed",
|
||||||
|
opened_at=now - timedelta(days=5), closed_at=now - timedelta(days=1),
|
||||||
|
close_price=95.0, close_reason="stop", book=MANUAL_BOOK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert ids["AAA"] in await get_reentry_gate_locks(session, book=MANUAL_BOOK)
|
||||||
|
assert ids["AAA"] not in await get_reentry_gate_locks(session, book=SHADOW_BOOK)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shadow_equity_ignores_manual_pnl(self, session):
|
||||||
|
ids = await _seed(session, ["AAA"])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
session.add(
|
||||||
|
PaperTrade(
|
||||||
|
user_id=1, ticker_id=ids["AAA"], direction="long", entry_price=100.0,
|
||||||
|
shares=100.0, stop_loss=95.0, target=115.0, status="closed",
|
||||||
|
opened_at=now - timedelta(days=5), closed_at=now,
|
||||||
|
close_price=150.0, close_reason="trailing", book=MANUAL_BOOK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
equity = await shadow_book_service.current_equity(session, 100_000.0)
|
||||||
|
|
||||||
|
assert equity == 100_000.0
|
||||||
Reference in New Issue
Block a user