fix: paper trade data pipeline — persist fills, positions, and wire dashboard#89
fix: paper trade data pipeline — persist fills, positions, and wire dashboard#89ajitpratap0 wants to merge 20 commits intomainfrom
Conversation
Adds TradesHandler in internal/api/trades.go with ListTrades supporting limit/offset query params, and registers it in cmd/api/routes.go after the dashboard handler block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ats correctly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…line
- internal/api/risk.go: RiskHandler with /risk/metrics, /risk/circuit-breakers,
/risk/exposure endpoints; uses []interface{} conversion for CalculateVaR
- internal/api/performance.go: PerformanceHandler with /performance/pairs endpoint
aggregating realized PnL by symbol across active sessions
- cmd/api/routes.go: register riskHandler and perfHandler in setupRoutes
- web/dashboard/lib/api.ts: add getTrades(), getRiskMetrics(), getCircuitBreakers(),
getRiskExposure(), getPairPerformance() methods to ApiClient
- web/dashboard/hooks/useTradeData.ts: fix useTrades() to hit real API (respects
USE_MOCK_DATA), normalize response to Trade[], fix equity curve hardcode to return
empty array instead of mock data when using real API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- hooks/usePerformance.ts: fix usePairPerformance, useRiskMetrics, useCircuitBreakers to use apiClient (proper /api/v1 prefix); add useRiskExposure hook; remove stale RiskMetrics/CircuitBreaker imports - app/risk/page.tsx: replace all hardcoded mock constants with real API data via useRiskMetrics/useCircuitBreakers/useRiskExposure hooks; map backend status strings (OK/WARNING/TRIGGERED) to component shape; compute exposure percentages from API totals; keep drawdown/alerts as static placeholders (no API equivalent yet) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4 tests verify the hook hits the real API when USE_MOCK_DATA=false: - returns Trade[] from API response wrapper - throws on API error (success:false) - returns empty array when response has unexpected shape - passes custom limit/offset to apiClient.getTrades Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: Paper Trade Data Pipeline (#89)Good work fixing a significant gap — the missing 🔴 Critical1. No DB transaction wrapping the fill pipeline ( The sequence // Wrap the entire fill pipeline in a transaction
tx, err := s.db.BeginTx(ctx)
if err != nil { ... }
defer tx.Rollback(ctx)
// ... all fill operations on tx ...
tx.Commit(ctx)2. TOCTOU race condition on paper session creation (
3. N+1 query pattern in
SELECT p.symbol, p.realized_pnl
FROM positions p
JOIN trading_sessions s ON s.id = p.session_id
WHERE s.status = 'active' AND p.exit_time IS NOT NULL AND p.realized_pnl IS NOT NULL🟡 Medium4. Position side doesn't handle closing an existing long ( A if existingPos != nil && existingPos.Side != posSide {
// closing or reducing — call ClosePosition or ReducePosition instead
}5. Exposure uses entry price, not mark price ( totalExposure += p.Quantity * p.EntryPriceThis misrepresents current exposure for positions with significant unrealised PnL. Either document this as "notional at entry" or use 6. Circuit breaker thresholds are hardcoded ( buildBreaker("Max Daily Loss", lossAmount, 5000) // $5000 hardcoded
buildBreaker("Max Drawdown %", maxDrawdown*100, 10) // 10% hardcoded
buildBreaker("Total Trade Count", float64(totalTrades), 100) // 100 hardcodedThese should come from the risk config ( 7. Pagination response missing total count ( c.JSON(http.StatusOK, gin.H{
"trades": trades,
"count": len(trades), // This is page size, not total
...
})The frontend can't determine whether to show a "Load more" control without knowing the total. Add 🟢 Low / Nits8. These are generic enough to belong in an 9. equity: (raw as { equity_curve?: EquityPoint[] }).equity_curve ?? []The dashboard PnL endpoint doesn't return 10. Exposure side: 'long' as const,The API doesn't include position side in the exposure endpoint, so this is always 11. returnsIface := make([]interface{}, len(returns))The ✅ Positives
|
…n side warning, pagination total - handlers_trading.go: fix TOCTOU race on paper session creation; if CreateSession fails (duplicate key from concurrent request), retry ListActiveSessions and reuse the existing paper session rather than returning 500 - handlers_trading.go: add zerolog Warn when a trade arrives on the opposite side of an existing open position (position close/reduce logic not yet implemented — TODO) - internal/db/positions.go: add GetAllClosedPositions() — single query across all sessions returning positions where exit_time IS NOT NULL - internal/api/risk.go: replace N+1 (ListActiveSessions + GetPositionsBySession loop) in collectClosedReturns with a single GetAllClosedPositions call - internal/api/performance.go: replace N+1 (ListActiveSessions + GetPositionsBySession loop) in aggregatePairPerformance with a single GetAllClosedPositions call - internal/db/trades.go: add CountAllTrades() helper - internal/api/trades.go: add total field to ListTrades response using CountAllTrades; failure is non-fatal (returns -1) so the page data is never blocked Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review — fix: paper trade data pipelineThis is a solid fix for a critical data pipeline gap. The root cause is correctly identified and the approach is sensible. Below are issues ranging from bugs to minor nits. 🐛 Bugs1. VaR units mismatch: dollar values rendered as percentages
// internal/api/risk.go — returns are dollar PnL, not pct returns
returns = append(returns, *p.RealizedPnL) // e.g. -234.56// app/risk/page.tsx — formats as pct
value={var95 != null ? formatPercentage(var95) : '—'}2. All exposure positions hardcoded as In side: 'long' as const, // ← always long, ignores actual position sideThe API doesn't return a 3. Session symbol constraint mismatch When creating a new paper session,
|
| Severity | Count |
|---|---|
| Bug | 3 (VaR units, session symbol, equity curve empty) |
| Logic Issue | 5 |
| Security | 1 |
| Code Quality | 4 |
The VaR units mismatch (#1) and missing auth (#9) should be fixed before merge. The rest can be addressed in follow-up issues.
🤖 Generated with Claude Code
PR Review: fix: paper trade data pipeline — persist fills, positions, and wire dashboardThis is a solid, well-scoped fix that addresses a real data pipeline gap. The root cause identification is accurate, the TODO comments are appropriately honest about known limitations, and the frontend wiring is clean. Below are issues ranging from correctness bugs to minor nits. 🔴 Bugs / Correctness Issues1. No database transaction wrapping the paper trade pipeline // handlers_trading.go ~line 440
if err := s.db.InsertOrder(ctx, order); err != nil { ... }
// ~100 lines later, if InsertTrade fails:
log.Warn().Err(err).Msg("Failed to insert paper trade fill row") // silently continuesRecommendation: wrap the fill block in a 2. Position averaging into opposite direction instead of closing // handlers_trading.go ~line 543
if existingPos != nil && existingPos.Side != posSide {
log.Warn()... // warns, then falls through
}
// ...
if existingPos == nil {
// create new position
} else {
// THIS branch runs for opposite-side trades too
totalQty := existingPos.Quantity + req.Quantity // adds to a long when selling!
weightedAvg := (existingPos.Quantity*existingPos.EntryPrice + req.Quantity*execPrice) / totalQty
s.db.UpdatePositionAveraging(...)
}The TODO is acknowledged, but silently corrupting position data is worse than returning a 400. Consider returning 3. Exposure calculation uses entry price, not current price // internal/api/risk.go ~line 56
for _, p := range openPositions {
totalExposure += p.Quantity * p.EntryPrice // cost basis, not market value
}🟡 Design / Performance Issues4. Go-side aggregation instead of SQL for performance/risk endpoints -- performance/pairs
SELECT symbol, SUM(realized_pnl) AS realized_pnl, COUNT(*) AS trade_count
FROM positions WHERE exit_time IS NOT NULL AND realized_pnl IS NOT NULL
GROUP BY symbol ORDER BY realized_pnl DESC
-- risk/exposure
SELECT symbol, SUM(quantity * entry_price) AS exposure
FROM positions WHERE exit_time IS NULL
GROUP BY symbol5. Hardcoded circuit breaker thresholds // internal/api/risk.go ~line 117
breakers := []gin.H{
buildBreaker("Max Daily Loss", lossAmount, 5000), // hardcoded
buildBreaker("Max Drawdown %", maxDrawdown*100, 10), // hardcoded
buildBreaker("Total Trade Count", float64(totalTrades), 100), // hardcoded
}At minimum these should be constants with a comment pointing to the risk config. Ideally read from 6. New // handlers_trading.go ~line 441
mockEx := exchange.NewMockExchange(s.db)
refPrice = mockEx.GetMarketPrice(req.Symbol)
🟡 Frontend Issues7. Silent API failure in risk page looks like "no risk" // app/risk/page.tsx ~line 69
const riskScore = useMemo(() => {
if (!circuitBreakers.length) return 100 // failure looks like "safe"
...
}, [circuitBreakers])The 8. Exposure // app/risk/page.tsx ~line 82
return raw.exposure.map(e => ({
...
side: 'long' as const, // always long, even for short positions
}))The backend 9. Mock data ignores pagination in // hooks/useTradeData.ts
if (USE_MOCK_DATA) {
return { success: true, data: getMockTrades(), ... } // ignores limit/offset
}Fine for dev, but worth a comment so future readers don't wonder why pagination params have no effect in mock mode. 🟢 Minor / Nits10. // internal/api/trades.go ~line 57
total, err := h.db.CountAllTrades(ctx)
if err != nil {
total = -1 // surprising sentinel for callers
}Prefer omitting the field on error or using 11. Paper session // handlers_trading.go ~line 417
newSession := &db.TradingSession{
Symbol: req.Symbol, // first trade's symbol becomes the session symbol
...
}If the session model is intended to be multi-asset, setting 12. if p.RealizedPnL != nil { // always true given the SQL filter
returns = append(returns, *p.RealizedPnL)
}Remove the guard or add a comment explaining the defense-in-depth intent. ✅ What's done well
The transaction issue (#1) is the highest priority to fix before merge — the rest are improvements that can be addressed in follow-up PRs given the January 2026 roadmap pressure. |
…n, config, auth, frontend Backend: - handlers_trading.go: wrap fill pipeline (InsertOrder→InsertTrade→CreatePosition) in pgx transaction for all-or-nothing semantics; rollback on any step failure - handlers_trading.go: return HTTP 422 for opposite-side trades instead of corrupting position data via averaging; prevents silent data corruption until close logic is impl - handlers_trading.go: move MockExchange to APIServer struct (shared instance, not per-req) - handlers_trading.go: set paper session Symbol to "PAPER" sentinel (multi-asset sessions) - handlers_trading.go: TOCTOU retry now guards on pgconn error code 23505 only - internal/api/risk.go: replace Go-side exposure aggregation with GetExposureBySymbol SQL GROUP BY; add cost-basis comment; move circuit breaker thresholds to RiskConfig; add VaR error logging; normalize PnL returns to fractional for VaR calculation - internal/api/performance.go: replace Go-side pair aggregation with GetPairPerformance SQL GROUP BY; remove aggregatePairPerformance method - internal/api/trades.go: change total sentinel from -1 to 0 with zerolog warning - internal/api/risk.go + trades.go + performance.go: add authMiddleware param to RegisterRoutes; applied on all routes; routes.go passes AuthMiddleware at call sites - internal/db/positions.go: add GetPairPerformance and GetExposureBySymbol SQL methods; add LIMIT 1000 ORDER BY exit_time DESC to GetAllClosedPositions - internal/db/trades.go: wrap scan/query errors with fmt.Errorf context; use O(1) pg_class.reltuples approximate count instead of SELECT COUNT(*) - internal/config/config.go: add MaxDailyLossDollars, MaxDrawdownPct, MaxTradeCount to RiskConfig with defaults; configs/config.yaml updated to match - cmd/api/main.go: initialize shared exchange.MockExchange on APIServer struct Frontend: - app/risk/page.tsx: destructure isError/isLoading from all three hooks; render error banner with retry button and loading indicator when API unavailable - app/risk/page.tsx: VaR values use formatCurrency (dollar amounts, not percentages); threshold updated to dollar-based comparison - app/risk/page.tsx: riskScore defaults to null → "Unknown"/"—" when no data - app/risk/page.tsx: remove hardcoded side:'long' from exposure mapping - hooks/useTradeData.ts: revert equity curve to getMockEquityPoints() with TODO comment - hooks/useTradeData.ts: add comment that mock mode ignores pagination params Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: Paper Trade Data PipelineThis is a solid and well-motivated fix. The root cause (missing Critical / Correctness1. Silent error suppression on _, _ = tx.Exec(ctx,
`UPDATE positions SET unrealized_pnl = $1 WHERE id = $2`,
unrealizedPnL, posID,
)Errors here are completely swallowed. If the UPDATE fails the committed position row has a stale or null 2. TOCTOU race on position lookup ( // Check for an opposite-side position BEFORE opening a transaction
existingPos, posErr := s.db.GetPositionBySymbolAndSession(ctx, req.Symbol, *sessionID)
...
// Then the position ID is used inside the transaction
_, txErr = tx.Exec(ctx, `UPDATE positions SET ... WHERE id = $1 AND exit_time IS NULL`, existingPos.ID, ...)The lookup happens before SELECT ... FROM positions WHERE symbol = $1 AND session_id = $2 AND exit_time IS NULL FOR UPDATE3. // pg_class.reltuples is an estimate updated by ANALYZE/autovacuum
SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'trades'The response field is named Design / Architecture4. Paper session lookup on every trade request (
5. Both queries aggregate across all sessions (paper and live). When live trading is added, exposure and pair performance data will be incorrectly mixed. A 6. if current >= threshold { // 0 >= 0 → TRIGGERED for any current value
status = "TRIGGERED"
} else if threshold > 0 && current/threshold >= 0.8 {
...
}If Code Quality7. Inlined SQL in transaction vs. DB layer methods ( The transaction executes raw SQL for The standard Go pattern for this is to accept func (db *DB) InsertTrade(ctx context.Context, tx pgx.Tx, trade *Trade) error { ... }This is a bigger refactor, but the current approach of duplicating SQL in the handler is a maintenance burden. 8. Hardcoded commissionAsset := "USDT"This is incorrect for BTC-quoted pairs (e.g., Testing9. Integration test only covers happy-path market BUY ( The test is a good start, but the following branches are completely untested:
Given the previous data loss bug being in this exact code path, higher test coverage is warranted. 10. No test for the new REST endpoints
Minor / Nits
Summary
The core fix is correct and the transaction wrapping is exactly right. The TOCTOU race on position lookup and the silent |
…alidation Backend: - internal/db/db.go: add WithTx(ctx, fn) helper encapsulating begin/commit/rollback; callers never touch pgxpool directly - internal/db/orders.go: add UpdateOrderStatusTx for transactional order updates; eliminates duplicated inline SQL in handlers_trading.go - internal/db/positions.go: add GetOpenPositionBySymbolTx for in-transaction position lookup; eliminates TOCTOU race where concurrent orders both see existingPos==nil - cmd/api/handlers_trading.go: move existingPos fetch inside WithTx callback; use UpdateOrderStatusTx; include unrealized_pnl in INSERT (removes silent tx.Exec discard); use quoteAsset() helper for commissionAsset instead of hardcoded "USDT"; return generic "internal server error" to clients (log real error server-side) - internal/api/risk.go: buildBreaker returns "DISABLED" when threshold <= 0 (prevents false triggers on unset config); rename "Max Daily Loss" → "Session Total Loss" (was using lifetime TotalPnL, not daily — label now accurate) Frontend: - app/risk/page.tsx: add isRiskMetrics/isCircuitBreakers/isExposure runtime type guards; replace unsafe casts with guarded assignments; bad API shapes log console.warn and fall back to empty/undefined instead of silently corrupting state - hooks/usePerformance.ts: give useRiskExposure its own top-level query key ['risk-exposure'] instead of nesting under risk; prevents unintended cache invalidation when risk queries are invalidated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: fix: paper trade data pipeline — persist fills, positions, and wire dashboardThis is a solid fix for a critical data pipeline gap. The transaction wrapping, TOCTOU race handling on session creation, and the sentinel Bugs 1. Race condition in position creation — SELECT FOR UPDATE missing
The comment correctly describes the intent but the fix is incomplete. Fix: add SELECT ... FROM positions
WHERE symbol = AND session_id = AND exit_time IS NULL
ORDER BY entry_time DESC
LIMIT 1
FOR UPDATE2. Order row left in NEW status if transaction fails The order is inserted before Consider either inserting the order inside the same 3. Frontend Trade type mismatch in useTrades The hook casts the raw API array directly to const tradeList: Trade[] =
raw && typeof raw === 'object' && 'trades' in raw
? (raw as { trades: Trade[] }).trades
: []The backend serialises the Go 4. DISABLED circuit breaker status silently mapped to normal
status: cb.status === 'TRIGGERED' ? 'triggered' : cb.status === 'WARNING' ? 'warning' : 'normal',A disabled breaker renders as green/OK rather than inactive, which could mislead operators into thinking risk controls are active when they are unconfigured. Medium Concerns 5. CountAllTrades not schema-scoped "SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'trades'"
SELECT reltuples::bigint FROM pg_class
WHERE relname = 'trades'
AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')6. Hardcoded 0.1% slippage vs configurable commission rate
7. GetAllClosedPositions LIMIT 1000 silently truncates VaR sample If the system runs more than 1000 paper trades (easy in automated testing), VaR is computed on a truncated sample with no warning to the caller. Consider emitting a Minor / Style 8. pgx.TxOptions{} defaults to READ COMMITTED — document intent or upgrade The main caller relies on isolation for position deduplication. Consider documenting the isolation level explicitly or using 9. ptrF64 helper — consider moving to internal/db The "verified via grep" comment will go stale. A one-liner in 10. TradingSession.Symbol = "PAPER" magic value
What's good
Critical before merge: Items 1, 2, and 3 (race condition, orphan orders, Trade type mismatch). Items 4-7 can be filed as follow-up issues. |
…e mapping, disabled status Backend: - internal/db/positions.go: add FOR UPDATE to GetOpenPositionBySymbolTx; prevents concurrent transactions from both observing existingPos==nil and inserting duplicate positions; emit log.Warn when GetAllClosedPositions hits LIMIT 1000 (truncated VaR) - internal/db/db.go: add pgx.TxOptions param to WithTx so callers can set isolation level - cmd/api/handlers_trading.go: use RepeatableRead isolation + FOR UPDATE for position dedup; cancel orphaned order (UpdateOrderStatus→CANCELED) when fill transaction fails; replace inline 1.001/0.999 with named constants paperSlippageBuy/paperSlippageSell; use db.PtrFloat64 instead of local ptrF64; add TODO on Symbol="PAPER" sentinel - internal/db/utils.go: new file — exported PtrFloat64 helper replaces per-file ptrF64 - internal/db/trades.go: scope CountAllTrades pg_class query to public schema via relnamespace to avoid collisions with same-named tables in other schemas - internal/db/orders.go: UpdateOrderStatusTx updated to match new WithTx opts signature Frontend: - hooks/useTradeData.ts: add RawApiTrade interface matching backend PascalCase wire shape (no JSON tags on db.Trade → encoding/json uses field names as-is); add mapApiTrade() converter; replace unsafe cast with .map(mapApiTrade) — fields no longer undefined - lib/types.ts: add 'disabled' to CircuitBreaker.status union type - app/risk/page.tsx: map backend DISABLED→disabled status; skip disabled breakers in riskScore calculation to avoid inflating risk score with unconfigured controls - components/risk/CircuitBreakerStatus.tsx: render disabled breakers in muted/neutral style with "Threshold not configured" message; add Disabled count to summary footer; overall status = 'disabled' when all breakers are disabled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code Review — PR #89: Paper trade data pipeline fixGreat direction overall — fixing a silent data loss bug and wiring the dashboard to real data in one PR is valuable. The transaction design and TOCTOU handling show careful thinking. Below are issues from critical to low. Critical1. The comment says "RepeatableRead + FOR UPDATE on positions prevents concurrent orders from both observing existingPos == nil and each inserting a duplicate position." This is not accurate. The correct fix is a partial unique index: CREATE UNIQUE INDEX positions_open_symbol_session_uniq
ON positions (session_id, symbol)
WHERE exit_time IS NULL;With that index, one of the two concurrent INSERTs will hit a unique constraint violation (23505), which can be caught and retried. 2. Order row inserted outside the transaction (
Better approach: move 3. Raw SQL embedded in handler bypasses the DB layer ( The trade INSERT and position INSERT/UPDATE are raw
Medium4.
5. No limit on the GROUP BY query — at scale this could return thousands of symbols. Add 6. Integration test does not cover the The test body includes 7. Circuit breaker label vs config field naming mismatch ( The config field is Low8.
9.
Viper's 10. When 11. The suffix matching loop is correct for standard symbols but silently falls back to Positive observations
The phantom-insert race (#1) and the order-outside-transaction atomicity issue (#2) are the blockers to address before merging. The raw SQL in handler (#3) is a strong suggestion for a follow-up if not addressed here. |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: fix: paper trade data pipeline — persist fills, positions, and wire dashboardThis is a solid fix for a critical missing piece of the paper trading pipeline. The root cause (fills/positions never persisted) is correctly identified and fixed, the transaction design is thoughtful, and the frontend wiring is clean. A few issues below ranging from structural to minor. High — Non-atomic order insert / fill splitFile: The order row is inserted before the High — Raw SQL inside handler bypasses the DB layerFile: The trade fill and position inserts use Medium —
|
…ts, VaR dollar scaling Backend: - internal/db/orders.go: add InsertOrderTx, InsertTradeTx (transactional variants) - internal/db/positions.go: add CreatePositionTx, UpdatePositionAveragingTx; switch GetAllClosedPositions from LIMIT 1000 to 90-day time window (more meaningful for VaR, no log-spam when limit hit) - cmd/api/handlers_trading.go: move InsertOrderTx inside WithTx callback so order+fill+ position are one atomic unit; replace all inline tx.Exec SQL with db-layer TX methods; remove orphan-cancellation cleanup path (no longer needed); limit orders still use non-transactional InsertOrder since they have no fill step; use s.config.Trading.InitialCapital instead of hardcoded 100_000.0 - internal/api/risk.go: multiply fractional VaR by total portfolio value (sum of InitialCapital across active sessions) to produce dollar VaR — fixes display showing $0.02 instead of a meaningful dollar amount - internal/db/trades.go: CountAllTrades uses current_schema() instead of hardcoded 'public' — portable across multi-schema deployments - migrations/018_positions_symbol_index.sql: CREATE INDEX CONCURRENTLY on (session_id, symbol, exit_time) WHERE exit_time IS NULL — supports GetOpenPositionBySymbolTx FOR UPDATE without full table scan - configs/config.yaml: initial_capital 10000 → 100000 (realistic paper trading default) Frontend: - app/risk/page.tsx: extract VAR_WARNING_THRESHOLD = 500 named constant (dollar-based) replacing inline magic number - hooks/useTradeData.ts: mapApiTrade derives status from raw.Side (SELL→'closed', BUY→'open') so BUY fills on open positions no longer appear as closed in the UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e with transaction blocks CREATE INDEX CONCURRENTLY cannot run inside a transaction block; migration runners wrap each migration in a transaction. Plain CREATE INDEX IF NOT EXISTS is used instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review — fix: paper trade data pipelineThis is a solid, well-scoped fix. The core problem (missing Bugs / Correctness Issues1.
type tradeResponse struct {
ID string `json:"id"`
OrderID string `json:"order_id"`
Symbol string `json:"symbol"`
...
}This is the pattern already used by 2. Integration test mock bypasses In // Current: doesn't exercise the real mapping path
data: { trades: [fakeTrade], count: 1 } as unknown as { trades: Trade[]; count: number }
// Better: use a RawApiTrade-shaped object so mapApiTrade is actually called
data: { trades: [{ ID: 'trade-abc', Symbol: 'BTCUSDT', Side: 'BUY', Price: 45045, ... }] }3. buildBreaker("Max Drawdown %", maxDrawdown*100, h.cfg.MaxDrawdownPct)This multiplies 4. PostgreSQL's 5. return tx.Commit(ctx)If if err := tx.Commit(ctx); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("commit transaction: %w", err)
}
return nilMissing Test Coverage
Performance Considerations
positions, err := h.db.GetAllClosedPositions(ctx) // up to 90 days, no limitIn a busy paper-trading environment this query could scan thousands of rows on every dashboard refresh. Consider:
Similarly Minor / Nits
Summary
The transaction-wrapped fill pipeline, the |
PR Review — fix: paper trade data pipelineGreat work fixing a fundamental gap in the paper trading pipeline. The transaction design is solid and the frontend wiring is clean. A few issues worth addressing before merge. Critical
// db.Trade today — no json tags, so Go emits PascalCase keys
type Trade struct {
ID uuid.UUID
Price float64
...
}// frontend breaks silently if json tags are ever added
interface RawApiTrade {
ID: string
Price: numberThis is a landmine: the moment anyone adds Moderate
# configs/config.yaml
- initial_capital: 10000.0
+ initial_capital: 100000.0Any new PAPER session created after this deploy starts with $100k, which changes position sizing behaviour and circuit breaker math across the whole system. This looks like it belongs in a separate config discussion, not bundled into a bug fix PR. If intentional, it deserves a config migration note in TASKS.md. VaR dollar scaling uses In for _, s := range activeSessions {
portfolioValue += s.InitialCapital // ← stale; should be CurrentCapital
}
// ...
response["var_95"] = varResult.VaR * portfolioValueDollar VaR (e.g. "$2,345 at 95% confidence") should be scaled by the current portfolio value, not the starting capital. After a winning or losing streak the number will be wrong. If
The 90-day cutoff is reasonable for a fresh system, but in production a long-running system may have relevant older returns. More importantly, a fresh system with < 10 closed positions will always return Minor
// risk/page.tsx — called directly in render, not in useMemo/useEffect
if (rawMetrics !== undefined && !isRiskMetrics(rawMetrics)) {
console.warn('Unexpected API response shape for risk metrics:', rawMetrics)
}If the API is returning an unexpected shape, this will spam the console on every re-render. Wrap the warn in a
status={overallStatus === 'disabled' ? 'disabled' : 'healthy'}
for _, suffix := range []string{"USDT", "BUSD", "BTC", "ETH", "BNB"} {This works correctly for all common Binance pairs today. The TODO to make slippage configurable should also note making the suffix list configurable, since exchanges like Kraken use different quote assets. What's done well
Overall this is a solid, well-structured fix. Resolve the JSON tag fragility before merge; the capital config change and VaR scaling should at minimum be called out explicitly in comments or follow-up tickets. |
…le.warn, config note Backend: - internal/db/orders.go: add snake_case json struct tags to all db.Trade fields (id, order_id, exchange_trade_id, symbol, exchange, side, price, quantity, quote_quantity, commission, commission_asset, executed_at, is_maker, metadata, created_at) — decouples wire format from Go field names; metadata uses omitempty - internal/api/risk.go: add NOTE comment explaining VaR scales by InitialCapital (not CurrentCapital — field doesn't exist yet on TradingSession); add debug log when data_points < 10 so operators know why VaR shows nil - cmd/api/handlers_trading.go: add TODO to quoteAsset suffix list re: configurable list for non-Binance exchanges (Kraken etc.) - configs/config.yaml: add explanatory comment on initial_capital 100000 change - TASKS.md: note initial_capital config change scope Frontend: - hooks/useTradeData.ts: update RawApiTrade interface and mapApiTrade to use snake_case field names matching the new json tags (id, order_id, executed_at, is_maker, etc.) — was PascalCase which would break silently on any tag addition - app/risk/page.tsx: move console.warn calls for bad API shapes out of render-time code into useEffect hooks keyed on each response object — prevents console spam on every re-render when API returns unexpected shape Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review — fix: paper trade data pipelineOverall this is a solid, well-scoped fix. The core idea (wrapping all fill writes in a single RepeatableRead transaction) is correct and the code is clearly commented. A few issues below ranging from critical to minor. Critical — Phantom insert race in GetOpenPositionBySymbolTxFOR UPDATE in PostgreSQL only locks existing rows. If no position exists for a symbol, two concurrent market-buy requests will both execute SELECT ... FOR UPDATE (both return nil), both decide existingPos == nil, and both try to INSERT a new position. Result: duplicate open positions for the same (session_id, symbol). RepeatableRead prevents phantom reads for rows that existed at snapshot time, but it does not block two transactions from each inserting a new row for the same key. Fix options (pick one):
Migration 018_positions_symbol_index.sql adds a partial index, not a unique constraint, so this race is still live. High — Silent VaR unit mismatch when ListActiveSessions failsIn internal/api/risk.go, if the ListActiveSessions call fails, portfolioValue stays 0 and the API silently returns fractional VaR (e.g. 0.023) instead of dollar VaR (e.g. 2300). The frontend uses formatCurrency() and will display $0.02 — valid-looking but meaningless. Suggest adding a "var_unit" field or surfacing the session error as a warning so the frontend can detect degraded mode. High — Tx helper methods duplicate SQL across non-Tx counterpartsInsertOrderTx/InsertOrder, InsertTradeTx/InsertTrade, and UpdateOrderStatusTx/UpdateOrderStatus each duplicate their SQL string and bind argument list. Any schema change must be applied to both copies. Consider extracting a shared SQL const and a helper that accepts a db.Querier interface so there is one source of truth. Medium — CountAllTrades is search_path-sensitiveThe query uses current_schema() to find the trades table in pg_class. This can return the wrong schema if the connection overrides search_path (TimescaleDB adds its own schema). Prefer 'public'::regnamespace or to_regclass('public.trades') for a stable reference. Medium — WithTx panic rollback should use context.Background()The deferred rollback in WithTx calls tx.Rollback(ctx), but if the panic was triggered by a cancelled context, this rollback will also fail. Use context.Background() for the rollback to ensure it always runs. Medium — Paper session resolution is outside the trade transactionThe session lookup and optional CreateSession happen before WithTx. If CreateSession succeeds but WithTx fails, an empty session is committed. If AggregateSessionStats (also outside the tx) crashes after a successful commit, stats are stale until the next trade. Low probability, but worth a comment explaining the intentional consistency trade-off. Positives
Minor nits
|
…Tx context, SQL dedup, VaR unit field
Critical:
- migrations/019: replace non-unique partial index (018) with UNIQUE partial index
on (session_id, symbol) WHERE exit_time IS NULL — prevents phantom duplicate
open positions when two concurrent inserts both observe existingPos == nil
- internal/db/db.go: use context.Background() for panic-path rollback in WithTx
so rollback succeeds even when ctx is already cancelled
High:
- internal/db/orders.go: extract sqlInsertOrder, sqlInsertTrade, sqlUpdateOrderStatus
package constants — Tx and non-Tx variants now share one SQL source of truth
- internal/api/risk.go: add var_unit field ("dollar"/"fractional") to VaR response;
log warning when ListActiveSessions fails so degraded VaR is visible to operators
Medium:
- internal/db/trades.go: CountAllTrades uses to_regclass('public.trades') instead
of current_schema() — stable under TimescaleDB search_path modifications
Minor:
- cmd/api/handlers_trading.go: comment explaining session/tx boundary is intentional;
quoteAsset suffix ordering explanation with non-Binance configurable TODO
- web/dashboard/hooks/useTradeData.ts: agent proxy TODO comment
- TASKS.md: move CONFIG entry to a note block (no task ID or estimate)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: fix: paper trade data pipeline — persist fills, positions, and wire dashboardThis is a well-structured fix for a critical data-pipeline gap. The transaction design, locking strategy, and test coverage are solid. A few items worth addressing before merge: Issues1.
|
…sions, test coverage, mock drawdown
Backend:
- internal/db/positions.go: add LIMIT 200 to GetPairPerformance and GetExposureBySymbol
to prevent unbounded result sets on the dashboard
- internal/api/risk.go: skip VaR calculation entirely when portfolioValue == 0
(no active sessions or ListActiveSessions failure) — var_95/99 stay nil so
frontend shows '—' instead of misleading fractional values; remove var_unit field
- cmd/api/paper_trade_pipeline_test.go: add two sub-tests — one exercises the
GetMarketPrice code path (market order with no price), another verifies the 400
error path when no market price is seeded
- migrations/019: add explanatory comment on why 018 and 019 are separate migrations
Frontend:
- web/dashboard/app/risk/page.tsx:
- add var_unit to RawRiskMetrics type; derive formatVar that switches between
formatCurrency (dollar) and formatPercentage (fractional) based on var_unit
- fix duplicate exposure: Open Positions subtitle now shows avg position size
instead of duplicating the Total Exposure card value
- replace mockDrawdown + DrawdownChart with empty-state placeholder and TODO;
remove unused mockDrawdown constant, DrawdownChart import, and timeRange state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: Paper Trade Data PipelineGood foundational work — the core bug fix is well-reasoned and the transaction design is solid. A few issues worth addressing before merge. Bugs / Correctness1. In if err := fn(tx); err != nil {
_ = tx.Rollback(ctx) // ctx may be Done() here
return err
}If 2.
3.
const varUnit = isRiskMetrics(rawMetrics) ? (rawMetrics.var_unit ?? 'dollar') : 'dollar'But 4. Session creation retry loses the error context on lookup failure sessions2, err2 := s.db.ListActiveSessions(ctx)
if err2 == nil {
...
}
if sessionID == nil {
log.Error().Err(err).Msg("Failed to find paper session after unique constraint violation")If if err2 != nil {
log.Error().Err(err2).Msg("Retry lookup after 23505 also failed")
...
}Performance5. No index on
CREATE INDEX IF NOT EXISTS idx_trades_executed_at ON trades (executed_at DESC);(Could go in migration 020 or alongside migration 018/019.) 6.
Design Notes (non-blocking)7. The session resolution, price calculation, and fill transaction are all inline in one handler. This makes unit-testing individual pieces difficult. Extracting 8. Limit order path doesn't go through Market orders are now fully atomic via 9. status: (raw.side === 'SELL' ? 'closed' : 'open') as Trade['status'],The comment acknowledges this is a simplification, which is fine. But it means the trades list shows every BUY fill as "open" even after the position is closed. Consider whether this creates user confusion on the dashboard; if so a What's Done Well
Summary
Items 1–4 are worth fixing before merge. Items 5–9 are suggestions. |
Add unit tests for functions that don't require a DB connection: - internal/exchange: fix safety_guard tests — guard is always non-nil (NewService always calls risk.NewSafetyGuard); rewrite to test disabled guard behaviour (SetCapital, RecordTrade, EmergencyStop, ClearEmergencyStop, ResetCircuitBreaker, GetStats, GetEmergencyStopStatus, GetTradingHoursStatus) - internal/backtest: validateJob (8 cases), getValue, getIntValue - internal/market: CoinGeckoIDToBinanceSymbol, EstimateOHLCV, ParseStringFloat - internal/models: BinancePositionToUnified, BinanceTradeToUnified, Portfolio ops - internal/polymarket/analyzer: parseResponse (valid/embedded/invalid/empty) - internal/polymarket/news: stripHTML (7 cases) - internal/polymarket/resolver: calculatePredictionPnl (5 cases) - internal/api: itoa, parseDuration, buildBreaker, NewRiskHandler constructors - internal/db: PtrFloat64 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: fix: paper trade data pipelineThis is a substantial, well-structured PR that fixes a genuine data pipeline gap. The transaction design, migration strategy, and inline documentation are all high quality. What is Done Well
Bugs / Correctness Issues1. err2 is silently swallowed in the 23505 retry block (cmd/api/handlers_trading.go) When err2 != nil (the retry lookup itself fails), the error is silently discarded and the subsequent log still reports the original 23505 error. This makes it impossible to distinguish session-not-found-after-retry from the-retry-DB-call-failed. Suggest early-returning on non-nil err2 and logging err2 separately before the existing sessionID == nil check. 2. Extra UPDATE on a just-inserted row (minor) InsertOrderTx writes OrderStatusNew, then UpdateOrderStatusTx immediately overwrites it to FILLED within the same transaction. Logically correct but adds an unnecessary round-trip. Consider inserting market orders with OrderStatusFilled directly. Design Concerns3. Config field name mismatch: MaxDailyLossDollars vs actual semantics The field tracks total session loss, not daily loss (acknowledged in code comments). The config key is max_daily_loss_dollars, the struct field is MaxDailyLossDollars, and the circuit breaker label is "Session Total Loss" -- three different names for the same thing. A rename to MaxSessionLossDollars in a follow-up would prevent confusion if this value is ever referenced by the safety guard or alerting. 4. GetAllOpenPositions in GetMetrics is unbounded GetExposureBySymbol caps at LIMIT 200 in SQL. GetAllOpenPositions has no cap. If sessions accumulate many positions, this endpoint does a full table scan and loads every row into Go memory on every request. An aggregating SQL query (similar to GetExposureBySymbol) that computes count and total exposure directly would be more efficient. 5. CountAllTrades returns an estimate with no indication to the caller CountAllTrades uses pg_class.reltuples (documented as approximate). A client building pagination UI on the total field will see inconsistent page counts as autovacuum lags behind actual row count. Consider adding total_is_estimate to the response, or use a bounded subquery which is fast but exact for small tables. Security
Testing Gaps6. No test for quoteAsset The function has a non-trivial suffix-ordering dependency. A table-driven test covering ETHBTC, BNBETH, XRPBUSD, and an unrecognized symbol would pin the ordering contract and catch regressions. 7. GetCircuitBreakers with zero-value config is untested at handler level TestBuildBreaker covers buildBreaker well, but there is no handler-level test verifying that a zero-value RiskConfig (all thresholds unset) produces DISABLED status for all breakers. Since the dashboard renders these values directly, a handler test would provide confidence. Minor / Style8. Limit order path uses non-transactional InsertOrder Technically correct (limit orders have no downstream rows to roll back) but asymmetric with the market order path. A short inline comment explaining why no transaction is needed would help future contributors adding limit order fill logic. 9. Two migrations where one immediately supersedes the other Migration 018 (non-unique index) is immediately superseded by 019 (drop + unique index). For a fresh install both run: create index, drop it, create a different one. The forward-compatibility rationale is valid and well-commented. If 018 has not been deployed to any environment yet, collapsing them into a single migration would be cleaner. Summary
Item 1 (silent err2 swallow) is the only issue I would want addressed before merge. Items 4 and 5 are worth a TASKS.md entry if not fixed now. Everything else is follow-up material or style preference. |
Summary
handlePaperTradenever calledInsertTradeorCreatePositionafter filling an order — the entire data pipeline downstream of the order was missingGET /api/v1/trades(fill records),GET /api/v1/risk/metrics|circuit-breakers|exposure,GET /api/v1/performance/pairsuseTrades,useRiskMetrics,useCircuitBreakers,useRiskExposure,usePairPerformanceall call the real API; risk page replaces hardcoded mock constants with live dataChanges
Backend (Go)
cmd/api/handlers_trading.go—handlePaperTradenow resolves/creates a PAPER session, callsGetMarketPrice+ inline slippage, inserts trade fill viaInsertTrade, creates/updates position viaCreatePosition/UpdatePositionAveraginginternal/db/trades.go— addListAllTrades(ctx, limit, offset)helperinternal/api/trades.go—TradesHandlerservingGET /api/v1/tradesinternal/api/risk.go—RiskHandlerserving VaR metrics, circuit breakers, exposureinternal/api/performance.go—PerformanceHandlerserving per-symbol realized PnLcmd/api/routes.go— register all new handlersFrontend (Next.js)
lib/api.ts— addgetTrades,getRiskMetrics,getCircuitBreakers,getRiskExposure,getPairPerformancetoApiClienthooks/useTradeData.ts—useTradeshits real API, normalizes toTrade[], respectsUSE_MOCK_DATA; equity curve no longer hardcodedhooks/usePerformance.ts— fix rawfetchcalls missing/api/v1prefix; switch toapiClient; adduseRiskExposureapp/risk/page.tsx— remove all hardcoded mock constants; wire to real hooksTest Plan
go test -tags=integration -run TestPaperTrade_PersistsAllRows ./cmd/api/...— passes (order FILLED, trade fill in DB, open position created)npm testinweb/dashboard— 14/14 pass including 4 newuseTradeshook testsgo build ./...— cleannpm run type-check— clean🤖 Generated with Claude Code