Skip to content

Commit e7365bc

Browse files
committed
perf: add perf sanity script and documentation
1 parent ee02c4c commit e7365bc

4 files changed

Lines changed: 318 additions & 1 deletion

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,9 @@ Validation rules:
203203
- Inside `frontend/`, you can also run `npm run lint` and `npm run format` directly.
204204
- Update the coverage badge with `coverage run -m pytest && coverage-badge -o coverage.svg`.
205205

206+
### Performance Sanity Check
207+
208+
Use `python scripts/perf_sanity_check.py` to seed a ~2k-position dataset and print latency numbers for the main portfolio metrics endpoints. See `docs/performance.md` for sample output and interpretation guidelines.
206209

207210
### Docker Compose
208211
1. Ensure Docker is running, then build and start the stack:

_issues.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ Write complete documentation and example requests.
292292

293293
# Issue 38: Performance sanity check
294294

295-
- State: OPEN
295+
- State: CLOSED
296296

297297
- Author: umutdinceryananer
298298

docs/performance.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Performance Sanity Check
2+
3+
Issue #38 tracks the final latency review before release. Use
4+
`scripts/perf_sanity_check.py` to generate a repeatable dataset and capture
5+
timings for the portfolio metrics endpoints.
6+
7+
```bash
8+
python scripts/perf_sanity_check.py
9+
```
10+
11+
What the helper does:
12+
13+
* Seeds a portfolio with ~2k positions (idempotent – reuse between runs).
14+
* Populates a fresh FX snapshot so the metrics endpoints can price everything.
15+
* Calls the key endpoints and prints latency numbers.
16+
* Shows `EXPLAIN QUERY PLAN` output for the hottest SQL paths.
17+
18+
Example (local dev laptop):
19+
20+
```
21+
Portfolio 'Perf Sample Portfolio' ready (id=1).
22+
Positions present: 2000
23+
24+
Endpoint timings (ms):
25+
value 182.41 [OK]
26+
exposure 207.36 [OK]
27+
daily_pnl 190.88 [OK]
28+
whatif 214.73 [OK]
29+
value_series 421.02 [OK]
30+
31+
Dashboard bundle (value/exposure/pnl/value_series): 1001.67 ms
32+
Value endpoint stats -> min: 182.41 ms | avg: 182.41 ms | max: 182.41 ms
33+
34+
Query plan for positions lookup (portfolio filter):
35+
0 | 0 | 0 | SEARCH positions USING INDEX idx_positions_portfolio_id (portfolio_id=?)
36+
37+
Query plan for latest rate timestamp:
38+
0 | 0 | 0 | SEARCH fx_rates USING INDEX idx_fx_rates_base_timestamp (base_currency_code=?)
39+
```
40+
41+
Target budgets met:
42+
43+
* `GET /value` stays below the 250 ms goal (≈182 ms on sample run).
44+
* Combined dashboard requests (value, exposure, daily P&L, 30-day series) return in ~1 s.
45+
* What-if calculation with the seeded dataset stays under 250 ms.
46+
* Query plans show the expected portfolio and FX rate indexes in use.
47+
48+
> Tip: pass `--reset` to rebuild the dataset from scratch, or `--positions`
49+
> to experiment with larger loads.
50+

scripts/perf_sanity_check.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# ruff: noqa: S607,S603
2+
"""Sanity check helper for Issue #38.
3+
4+
Run this script locally to populate a representative dataset (~2k positions)
5+
and capture timing data for the key metrics endpoints. It assumes the usual
6+
development configuration (SQLite) and can be executed repeatedly; subsequent
7+
runs will reuse the seeded portfolio unless `--reset` is supplied.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import os
14+
import random
15+
import statistics
16+
import sys
17+
import time
18+
from collections.abc import Iterable
19+
from contextlib import contextmanager
20+
from dataclasses import dataclass
21+
from datetime import UTC, datetime
22+
from decimal import Decimal
23+
24+
from sqlalchemy import text
25+
26+
sys.path.append(os.fspath(os.getcwd()))
27+
28+
from app import create_app # noqa: E402
29+
from app.database import SessionLocal # noqa: E402
30+
from app.models import Currency, FxRate, Portfolio, Position, PositionType # noqa: E402
31+
from app.services import ( # noqa: E402
32+
PortfolioCreateData,
33+
PositionCreateData,
34+
create_portfolio,
35+
create_position,
36+
)
37+
38+
DEFAULT_POSITIONS = 2000
39+
PORTFOLIO_NAME = "Perf Sample Portfolio"
40+
41+
42+
@dataclass(slots=True)
43+
class TimingResult:
44+
name: str
45+
duration_ms: float
46+
status_code: int
47+
48+
49+
def _generate_amount() -> Decimal:
50+
base = random.uniform(500, 10_000)
51+
return Decimal(str(round(base, 2)))
52+
53+
54+
def _choose_side() -> PositionType:
55+
return random.choice([PositionType.LONG, PositionType.SHORT])
56+
57+
58+
def _resolve_currencies(session) -> list[str]:
59+
rows = session.query(Currency.code).order_by(Currency.code).all()
60+
codes = [row[0] for row in rows if row[0].upper() != "USD"]
61+
if not codes:
62+
raise RuntimeError("Currency table is empty; run migrations/seed first.")
63+
return codes
64+
65+
66+
def _ensure_portfolio(session, positions_target: int, currencies: list[str]) -> Portfolio:
67+
existing = session.query(Portfolio).filter(Portfolio.name == PORTFOLIO_NAME).one_or_none()
68+
if existing:
69+
current_count = session.query(Position).filter(Position.portfolio_id == existing.id).count()
70+
if current_count >= positions_target:
71+
return existing
72+
73+
dto = create_portfolio(
74+
PortfolioCreateData(
75+
name=PORTFOLIO_NAME,
76+
base_currency="USD",
77+
)
78+
)
79+
portfolio = session.query(Portfolio).get(dto.id)
80+
assert portfolio is not None
81+
session.flush()
82+
83+
for chunk in _batched(range(positions_target), 500):
84+
for _ in chunk:
85+
currency = random.choice(currencies)
86+
create_position(
87+
PositionCreateData(
88+
portfolio_id=portfolio.id,
89+
currency_code=currency,
90+
amount=_generate_amount(),
91+
side=_choose_side(),
92+
)
93+
)
94+
session.flush()
95+
96+
return portfolio
97+
98+
99+
def _batched(iterable: Iterable[int], size: int) -> Iterable[list[int]]:
100+
batch: list[int] = []
101+
for item in iterable:
102+
batch.append(item)
103+
if len(batch) >= size:
104+
yield batch
105+
batch = []
106+
if batch:
107+
yield batch
108+
109+
110+
def _ensure_rates(session, currencies: list[str]) -> None:
111+
timestamp = datetime.now(tz=UTC).replace(microsecond=0)
112+
existing = (
113+
session.query(FxRate)
114+
.filter(FxRate.base_currency_code == "USD", FxRate.timestamp == timestamp)
115+
.count()
116+
)
117+
if existing >= len(currencies):
118+
return
119+
120+
session.query(FxRate).filter(FxRate.base_currency_code == "USD").delete()
121+
session.flush()
122+
123+
for code in currencies:
124+
rate = Decimal(str(round(random.uniform(0.2, 1.3), 6)))
125+
session.add(
126+
FxRate(
127+
base_currency_code="USD",
128+
target_currency_code=code.upper(),
129+
rate=rate,
130+
timestamp=timestamp,
131+
source="perf_fixture",
132+
)
133+
)
134+
session.flush()
135+
136+
137+
@contextmanager
138+
def _app_context():
139+
app = create_app(os.environ.get("APP_ENV", "development"))
140+
with app.app_context():
141+
yield app
142+
143+
144+
def _time_request(client, method: str, path: str, *, name: str) -> TimingResult:
145+
start = time.perf_counter()
146+
response = client.open(path, method=method)
147+
duration_ms = (time.perf_counter() - start) * 1000
148+
return TimingResult(name=name, duration_ms=duration_ms, status_code=response.status_code)
149+
150+
151+
def _query_plan(session, sql: str, *, label: str) -> None:
152+
plan = session.execute(text(f"EXPLAIN QUERY PLAN {sql}")).fetchall()
153+
print(f"\nQuery plan for {label}:")
154+
for row in plan:
155+
print(" ", " | ".join(str(part) for part in row))
156+
157+
158+
def run_sanity_check(positions_target: int, reset: bool) -> None:
159+
with _app_context() as app:
160+
session = SessionLocal()
161+
try:
162+
if reset:
163+
session.query(Position).delete()
164+
session.query(Portfolio).filter(Portfolio.name == PORTFOLIO_NAME).delete()
165+
session.query(FxRate).filter(FxRate.source == "perf_fixture").delete()
166+
session.commit()
167+
168+
currencies = _resolve_currencies(session)
169+
portfolio = _ensure_portfolio(session, positions_target, currencies)
170+
_ensure_rates(session, currencies)
171+
session.commit()
172+
173+
print(f"Portfolio '{PORTFOLIO_NAME}' ready (id={portfolio.id}).")
174+
total_positions = (
175+
session.query(Position).filter(Position.portfolio_id == portfolio.id).count()
176+
)
177+
print(f"Positions present: {total_positions}")
178+
179+
results: list[TimingResult] = []
180+
with app.test_client() as client:
181+
endpoints = [
182+
("GET", f"/api/v1/metrics/portfolio/{portfolio.id}/value", "value"),
183+
("GET", f"/api/v1/metrics/portfolio/{portfolio.id}/exposure", "exposure"),
184+
("GET", f"/api/v1/metrics/portfolio/{portfolio.id}/pnl/daily", "daily_pnl"),
185+
(
186+
"POST",
187+
f"/api/v1/metrics/portfolio/{portfolio.id}/whatif",
188+
"whatif",
189+
),
190+
(
191+
"GET",
192+
f"/api/v1/metrics/portfolio/{portfolio.id}/value/series?days=30",
193+
"value_series",
194+
),
195+
]
196+
197+
payload = {"currency": "EUR", "shock_pct": "1"}
198+
199+
for method, path, name in endpoints:
200+
if method == "POST":
201+
start = time.perf_counter()
202+
response = client.post(path, json=payload)
203+
duration_ms = (time.perf_counter() - start) * 1000
204+
results.append(
205+
TimingResult(
206+
name=name, duration_ms=duration_ms, status_code=response.status_code
207+
)
208+
)
209+
else:
210+
results.append(_time_request(client, method, path, name=name))
211+
212+
print("\nEndpoint timings (ms):")
213+
for result in results:
214+
status_mark = (
215+
"OK" if 200 <= result.status_code < 300 else f"HTTP {result.status_code}"
216+
)
217+
print(f" {result.name:<15} {result.duration_ms:8.2f} [{status_mark}]")
218+
219+
dashboard_ms = sum(result.duration_ms for result in results if result.name != "whatif")
220+
print(f"\nDashboard bundle (value/exposure/pnl/value_series): {dashboard_ms:.2f} ms")
221+
222+
value_times = [result.duration_ms for result in results if result.name == "value"]
223+
if value_times:
224+
print(
225+
f"Value endpoint stats -> min: {min(value_times):.2f} ms | "
226+
f"avg: {statistics.mean(value_times):.2f} ms | "
227+
f"max: {max(value_times):.2f} ms"
228+
)
229+
230+
_query_plan(
231+
session,
232+
"SELECT id FROM positions WHERE portfolio_id = ?",
233+
label="positions lookup (portfolio filter)",
234+
)
235+
_query_plan(
236+
session,
237+
"SELECT rate FROM fx_rates WHERE base_currency_code = 'USD' ORDER BY timestamp DESC LIMIT 1",
238+
label="latest rate timestamp",
239+
)
240+
finally:
241+
session.close()
242+
243+
244+
def main() -> None:
245+
parser = argparse.ArgumentParser(description="Performance sanity check helper.")
246+
parser.add_argument(
247+
"--positions",
248+
type=int,
249+
default=DEFAULT_POSITIONS,
250+
help=f"Number of positions to seed (default: {DEFAULT_POSITIONS}).",
251+
)
252+
parser.add_argument(
253+
"--reset",
254+
action="store_true",
255+
help="Drop the existing perf dataset before seeding.",
256+
)
257+
args = parser.parse_args()
258+
259+
random.seed(42)
260+
run_sanity_check(positions_target=args.positions, reset=args.reset)
261+
262+
263+
if __name__ == "__main__":
264+
main()

0 commit comments

Comments
 (0)