Skip to content

Features/account pnls calendar#98

Merged
ifaouibadi merged 3 commits intodevelopfrom
features/account-pnls-calendar
Mar 31, 2026
Merged

Features/account pnls calendar#98
ifaouibadi merged 3 commits intodevelopfrom
features/account-pnls-calendar

Conversation

@ifaouibadi
Copy link
Copy Markdown
Member

@ifaouibadi ifaouibadi commented Mar 31, 2026

Note

Medium Risk
Changes PnL accounting semantics and adds new SQL-heavy aggregation paths, which could impact correctness/performance of portfolio endpoints. Also introduces a new public stats API route whose results depend on complex query logic.

Overview
Adds a new GET /accounts/:address/portfolio/stats endpoint (with TradingStats* DTOs) that returns win rate, top realized win (paired AE/USD), average hold duration, trade counts, and current unrealized profit for an address over a date range.

Reworks portfolio “range PnL” to be a daily realized PnL calendar: PortfolioService now builds per-snapshot [previousTs, snapshotTs) windows and calls new BclPnlService.calculateDailyPnlBatch() (timestamp-based SQL) while keeping cumulative value/PnL via calculateTokenPnlsBatch().

Updates BclPnlService PnL mapping semantics: cumulative PnL now treats invested as total spent and gain as sells + current value − spent (including closed positions), while range/daily PnL reports realized gains only using all-time average cost for tokens sold in-window; queries were extended to include cumulative buy totals and to return tokens with sells even if holdings are zero.

Hardens ops/runtime behavior: scripts/db-restore.sh resets the local DB user password after restore, and AePricingService.pullAndSaveCoinCurrencyRates() falls back to the last saved rates if saving new rates fails.

Written by Cursor Bugbot for commit ffc0cbc. This will update automatically on new commits. Configure here.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: New stats endpoint missing address name resolution
    • The stats endpoint now resolves .chain names through PortfolioService.resolveAccountAddress before querying trading stats so name-based lookups hit stored ak_ addresses.
  • ✅ Fixed: Top win mixes AE and USD from different trades
    • The trading-stats SQL now selects top_win_ae and top_win_usd from a single highest-gain sell row via a top_win CTE instead of independent MAX aggregations.

Create PR

Preview
diff --git a/src/account/controllers/accounts.controller.spec.ts b/src/account/controllers/accounts.controller.spec.ts
--- a/src/account/controllers/accounts.controller.spec.ts
+++ b/src/account/controllers/accounts.controller.spec.ts
@@ -23,6 +23,12 @@ describe('AccountsController', () => {
   let accountService: {
     getChainNameForAccount: jest.Mock;
   };
+  let portfolioService: {
+    resolveAccountAddress: jest.Mock;
+  };
+  let bclPnlService: {
+    calculateTradingStats: jest.Mock;
+  };
   let profileReadService: {
     getProfile: jest.Mock;
   };

@@ -23,6 +23,12 @@ describe('AccountsController', () => {
   let accountService: {
     getChainNameForAccount: jest.Mock;
   };
+  let portfolioService: {
+    resolveAccountAddress: jest.Mock;
+  };
+  let bclPnlService: {
+    calculateTradingStats: jest.Mock;
+  };
   let profileReadService: {
     getProfile: jest.Mock;
   };
@@ -37,28 +43,46 @@ describe('AccountsController', () => {
     accountService = {
       getChainNameForAccount: jest.fn(),
     };
+    portfolioService = {
+      resolveAccountAddress: jest.fn(),
+    };
+    bclPnlService = {
+      calculateTradingStats: jest.fn(),
+    };
     profileReadService = {
       getProfile: jest.fn(),
     };
 
     controller = new AccountsController(
       accountRepository as any,
-      {} as any,
+      portfolioService as any,
+      bclPnlService as any,
       accountService as any,
       profileReadService as any,
     );
   });
 
   it('returns paginated accounts', async () => {
-    const result = await controller.listAll(undefined, 1, 100, 'total_volume', 'DESC');
+    const result = await controller.listAll(
+      undefined,
+      1,
+      100,
+      'total_volume',
+      'DESC',
+    );
 
-    expect(accountRepository.createQueryBuilder).toHaveBeenCalledWith('account');
+    expect(accountRepository.createQueryBuilder).toHaveBeenCalledWith(
+      'account',
+    );
     expect(queryBuilder.leftJoin).not.toHaveBeenCalled();
     expect(queryBuilder.orderBy).toHaveBeenCalledWith(
       'account.total_volume',
       'DESC',
     );
-    expect(paginate).toHaveBeenCalledWith(queryBuilder, { page: 1, limit: 100 });
+    expect(paginate).toHaveBeenCalledWith(queryBuilder, {
+      page: 1,
+      limit: 100,
+    });
     expect(result).toEqual({ items: [], meta: {} });
   });
 

@@ -37,28 +43,46 @@ describe('AccountsController', () => {
     accountService = {
       getChainNameForAccount: jest.fn(),
     };
+    portfolioService = {
+      resolveAccountAddress: jest.fn(),
+    };
+    bclPnlService = {
+      calculateTradingStats: jest.fn(),
+    };
     profileReadService = {
       getProfile: jest.fn(),
     };
 
     controller = new AccountsController(
       accountRepository as any,
-      {} as any,
+      portfolioService as any,
+      bclPnlService as any,
       accountService as any,
       profileReadService as any,
     );
   });
 
   it('returns paginated accounts', async () => {
-    const result = await controller.listAll(undefined, 1, 100, 'total_volume', 'DESC');
+    const result = await controller.listAll(
+      undefined,
+      1,
+      100,
+      'total_volume',
+      'DESC',
+    );
 
-    expect(accountRepository.createQueryBuilder).toHaveBeenCalledWith('account');
+    expect(accountRepository.createQueryBuilder).toHaveBeenCalledWith(
+      'account',
+    );
     expect(queryBuilder.leftJoin).not.toHaveBeenCalled();
     expect(queryBuilder.orderBy).toHaveBeenCalledWith(
       'account.total_volume',
       'DESC',
     );
-    expect(paginate).toHaveBeenCalledWith(queryBuilder, { page: 1, limit: 100 });
+    expect(paginate).toHaveBeenCalledWith(queryBuilder, {
+      page: 1,
+      limit: 100,
+    });
     expect(result).toEqual({ items: [], meta: {} });
   });
 
@@ -91,4 +115,35 @@ describe('AccountsController', () => {
       NotFoundException,
     );
   });
+
+  it('resolves .chain addresses for portfolio stats queries', async () => {
+    portfolioService.resolveAccountAddress.mockResolvedValue('ak_resolved');
+    bclPnlService.calculateTradingStats.mockResolvedValue({
+      topWin: { ae: 5, usd: 10 },
+      unrealizedProfit: { ae: 20, usd: 40 },
+      winRate: 50,
+      avgDurationSeconds: 3600,
+      totalTrades: 4,
+      winningTrades: 2,
+    });
+
+    const result = await controller.getPortfolioStats('alice.chain', {});
+
+    expect(portfolioService.resolveAccountAddress).toHaveBeenCalledWith(
+      'alice.chain',
+    );
+    expect(bclPnlService.calculateTradingStats).toHaveBeenCalledWith(
+      'ak_resolved',
+      expect.any(Date),
+      expect.any(Date),
+    );
+    expect(result).toEqual({
+      top_win: { ae: 5, usd: 10 },
+      unrealized_profit: { ae: 20, usd: 40 },
+      win_rate: 50,
+      avg_duration_seconds: 3600,
+      total_trades: 4,
+      winning_trades: 2,
+    });
+  });
 });

@@ -91,4 +115,35 @@ describe('AccountsController', () => {
       NotFoundException,
     );
   });
+
+  it('resolves .chain addresses for portfolio stats queries', async () => {
+    portfolioService.resolveAccountAddress.mockResolvedValue('ak_resolved');
+    bclPnlService.calculateTradingStats.mockResolvedValue({
+      topWin: { ae: 5, usd: 10 },
+      unrealizedProfit: { ae: 20, usd: 40 },
+      winRate: 50,
+      avgDurationSeconds: 3600,
+      totalTrades: 4,
+      winningTrades: 2,
+    });
+
+    const result = await controller.getPortfolioStats('alice.chain', {});
+
+    expect(portfolioService.resolveAccountAddress).toHaveBeenCalledWith(
+      'alice.chain',
+    );
+    expect(bclPnlService.calculateTradingStats).toHaveBeenCalledWith(
+      'ak_resolved',
+      expect.any(Date),
+      expect.any(Date),
+    );
+    expect(result).toEqual({
+      top_win: { ae: 5, usd: 10 },
+      unrealized_profit: { ae: 20, usd: 40 },
+      win_rate: 50,
+      avg_duration_seconds: 3600,
+      total_trades: 4,
+      winning_trades: 2,
+    });
+  });
 });

diff --git a/src/account/controllers/accounts.controller.ts b/src/account/controllers/accounts.controller.ts
--- a/src/account/controllers/accounts.controller.ts
+++ b/src/account/controllers/accounts.controller.ts
@@ -208,9 +208,11 @@ export class AccountsController {
       ? moment(query.startDate).toDate()
       : moment().subtract(30, 'days').toDate();
     const end = query.endDate ? moment(query.endDate).toDate() : new Date();
+    const resolvedAddress =
+      await this.portfolioService.resolveAccountAddress(address);
 
     const stats = await this.bclPnlService.calculateTradingStats(
-      address,
+      resolvedAddress,
       start,
       end,
     );

@@ -208,9 +208,11 @@ export class AccountsController {
       ? moment(query.startDate).toDate()
       : moment().subtract(30, 'days').toDate();
     const end = query.endDate ? moment(query.endDate).toDate() : new Date();
+    const resolvedAddress =
+      await this.portfolioService.resolveAccountAddress(address);
 
     const stats = await this.bclPnlService.calculateTradingStats(
-      address,
+      resolvedAddress,
       start,
       end,
     );

diff --git a/src/account/services/bcl-pnl.service.spec.ts b/src/account/services/bcl-pnl.service.spec.ts
--- a/src/account/services/bcl-pnl.service.spec.ts
+++ b/src/account/services/bcl-pnl.service.spec.ts
@@ -134,10 +134,7 @@ describe('BclPnlService', () => {
       },
     ]);
 
-    const result = await service.calculateTokenPnlsBatch(
-      'ak_test',
-      [200, 300],
-    );
+    const result = await service.calculateTokenPnlsBatch('ak_test', [200, 300]);
 
     // Single DB round-trip regardless of number of heights
     expect(transactionRepository.query).toHaveBeenCalledTimes(1);

@@ -134,10 +134,7 @@ describe('BclPnlService', () => {
       },
     ]);
 
-    const result = await service.calculateTokenPnlsBatch(
-      'ak_test',
-      [200, 300],
-    );
+    const result = await service.calculateTokenPnlsBatch('ak_test', [200, 300]);
 
     // Single DB round-trip regardless of number of heights
     expect(transactionRepository.query).toHaveBeenCalledTimes(1);
@@ -151,7 +148,9 @@ describe('BclPnlService', () => {
     expect(sql).not.toContain('JOIN transactions tx'); // no repeated scan of transactions
     expect(sql).toContain('LEFT JOIN LATERAL');
     expect(sql).toContain('LIMIT 1');
-    expect(sql).not.toContain('DISTINCT ON (agg.snapshot_height, agg.sale_address)');
+    expect(sql).not.toContain(
+      'DISTINCT ON (agg.snapshot_height, agg.sale_address)',
+    );
     expect(params).toEqual(['ak_test', [200, 300]]);
 
     expect(result).toBeInstanceOf(Map);

@@ -151,7 +148,9 @@ describe('BclPnlService', () => {
     expect(sql).not.toContain('JOIN transactions tx'); // no repeated scan of transactions
     expect(sql).toContain('LEFT JOIN LATERAL');
     expect(sql).toContain('LIMIT 1');
-    expect(sql).not.toContain('DISTINCT ON (agg.snapshot_height, agg.sale_address)');
+    expect(sql).not.toContain(
+      'DISTINCT ON (agg.snapshot_height, agg.sale_address)',
+    );
     expect(params).toEqual(['ak_test', [200, 300]]);
 
     expect(result).toBeInstanceOf(Map);
@@ -291,7 +290,7 @@ describe('BclPnlService', () => {
       {
         sale_address: 'ct_pre_bought',
         current_holdings: '0',
-        total_volume_bought: '0',   // no buys in range
+        total_volume_bought: '0', // no buys in range
         total_amount_spent_ae: '0', // no buys in range
         total_amount_spent_usd: '0',
         total_amount_received_ae: '80',

@@ -291,7 +290,7 @@ describe('BclPnlService', () => {
       {
         sale_address: 'ct_pre_bought',
         current_holdings: '0',
-        total_volume_bought: '0',   // no buys in range
+        total_volume_bought: '0', // no buys in range
         total_amount_spent_ae: '0', // no buys in range
         total_amount_spent_usd: '0',
         total_amount_received_ae: '80',
@@ -474,6 +473,10 @@ describe('BclPnlService', () => {
     expect(sql).toContain('address_txs AS MATERIALIZED');
     expect(sql).toContain('token_agg');
     expect(sql).toContain('range_sells');
+    expect(sql).toContain('top_win AS');
+    expect(sql).toContain('ORDER BY gain_ae DESC, sell_at DESC');
+    expect(sql).not.toContain('MAX(gain_ae) FILTER (WHERE gain_ae > 0)');
+    expect(sql).not.toContain('MAX(gain_usd) FILTER (WHERE gain_usd > 0)');
     expect(sql).toContain('unrealized');
     expect(sql).toContain('CROSS JOIN unrealized');
     expect(params[0]).toBe('ak_test');

@@ -474,6 +473,10 @@ describe('BclPnlService', () => {
     expect(sql).toContain('address_txs AS MATERIALIZED');
     expect(sql).toContain('token_agg');
     expect(sql).toContain('range_sells');
+    expect(sql).toContain('top_win AS');
+    expect(sql).toContain('ORDER BY gain_ae DESC, sell_at DESC');
+    expect(sql).not.toContain('MAX(gain_ae) FILTER (WHERE gain_ae > 0)');
+    expect(sql).not.toContain('MAX(gain_usd) FILTER (WHERE gain_usd > 0)');
     expect(sql).toContain('unrealized');
     expect(sql).toContain('CROSS JOIN unrealized');
     expect(params[0]).toBe('ak_test');
@@ -516,6 +519,30 @@ describe('BclPnlService', () => {
     expect(result.winningTrades).toBe(2);
   });
 
+  it('calculateTradingStats keeps top_win ae and usd from same sell row', async () => {
+    const { service, transactionRepository } = createService();
+
+    transactionRepository.query.mockResolvedValue([
+      {
+        top_win_ae: '100',
+        top_win_usd: '5',
+        winning_sells: '2',
+        total_sells: '2',
+        avg_hold_secs: '1000',
+        unrealized_ae: '0',
+        unrealized_usd: '0',
+      },
+    ]);
+
+    const result = await service.calculateTradingStats(
+      'ak_test',
+      new Date('2026-01-01'),
+      new Date('2026-01-31'),
+    );
+
+    expect(result.topWin).toEqual({ ae: 100, usd: 5 });
+  });
+
   it('calculateTradingStats returns zero win_rate when no sells in range', async () => {
     const { service, transactionRepository } = createService();
 

@@ -516,6 +519,30 @@ describe('BclPnlService', () => {
     expect(result.winningTrades).toBe(2);
   });
 
+  it('calculateTradingStats keeps top_win ae and usd from same sell row', async () => {
+    const { service, transactionRepository } = createService();
+
+    transactionRepository.query.mockResolvedValue([
+      {
+        top_win_ae: '100',
+        top_win_usd: '5',
+        winning_sells: '2',
+        total_sells: '2',
+        avg_hold_secs: '1000',
+        unrealized_ae: '0',
+        unrealized_usd: '0',
+      },
+    ]);
+
+    const result = await service.calculateTradingStats(
+      'ak_test',
+      new Date('2026-01-01'),
+      new Date('2026-01-31'),
+    );
+
+    expect(result.topWin).toEqual({ ae: 100, usd: 5 });
+  });
+
   it('calculateTradingStats returns zero win_rate when no sells in range', async () => {
     const { service, transactionRepository } = createService();
 

diff --git a/src/account/services/bcl-pnl.service.ts b/src/account/services/bcl-pnl.service.ts
--- a/src/account/services/bcl-pnl.service.ts
+++ b/src/account/services/bcl-pnl.service.ts
@@ -217,16 +217,24 @@ export class BclPnlService {
       ),
       range_sells_with_gain AS (
         SELECT
+          sell_at,
           proceeds_ae - cost_ae AS gain_ae,
           proceeds_usd - cost_usd AS gain_usd,
           hold_secs
         FROM range_sells
       ),
+      top_win AS (
+        SELECT
+          gain_ae AS top_win_ae,
+          gain_usd AS top_win_usd
+        FROM range_sells_with_gain
+        WHERE gain_ae > 0
+        ORDER BY gain_ae DESC, sell_at DESC
+        LIMIT 1
+      ),
       -- Aggregate sell stats over the range
       sell_stats AS (
         SELECT
-          COALESCE(MAX(gain_ae) FILTER (WHERE gain_ae > 0), 0) AS top_win_ae,
-          COALESCE(MAX(gain_usd) FILTER (WHERE gain_usd > 0), 0) AS top_win_usd,
           COUNT(*) FILTER (WHERE gain_ae > 0) AS winning_sells,
           COUNT(*) AS total_sells,
           COALESCE(AVG(hold_secs) FILTER (WHERE hold_secs IS NOT NULL AND hold_secs >= 0), 0) AS avg_hold_secs

@@ -217,16 +217,24 @@ export class BclPnlService {
       ),
       range_sells_with_gain AS (
         SELECT
+          sell_at,
           proceeds_ae - cost_ae AS gain_ae,
           proceeds_usd - cost_usd AS gain_usd,
           hold_secs
         FROM range_sells
       ),
+      top_win AS (
+        SELECT
+          gain_ae AS top_win_ae,
+          gain_usd AS top_win_usd
+        FROM range_sells_with_gain
+        WHERE gain_ae > 0
+        ORDER BY gain_ae DESC, sell_at DESC
+        LIMIT 1
+      ),
       -- Aggregate sell stats over the range
       sell_stats AS (
         SELECT
-          COALESCE(MAX(gain_ae) FILTER (WHERE gain_ae > 0), 0) AS top_win_ae,
-          COALESCE(MAX(gain_usd) FILTER (WHERE gain_usd > 0), 0) AS top_win_usd,
           COUNT(*) FILTER (WHERE gain_ae > 0) AS winning_sells,
           COUNT(*) AS total_sells,
           COALESCE(AVG(hold_secs) FILTER (WHERE hold_secs IS NOT NULL AND hold_secs >= 0), 0) AS avg_hold_secs
@@ -254,19 +262,22 @@ export class BclPnlService {
         WHERE ta.current_holdings > 0
       )
       SELECT
-        ss.top_win_ae,
-        ss.top_win_usd,
+        COALESCE(tw.top_win_ae, 0) AS top_win_ae,
+        COALESCE(tw.top_win_usd, 0) AS top_win_usd,
         ss.winning_sells,
         ss.total_sells,
         ss.avg_hold_secs,
         u.unrealized_ae,
         u.unrealized_usd
       FROM sell_stats ss
       CROSS JOIN unrealized u
+      LEFT JOIN top_win tw ON true
     `;
   }
 
-  private mapTradingStats(row: Record<string, any> | undefined): TradingStatsResult {
+  private mapTradingStats(
+    row: Record<string, any> | undefined,
+  ): TradingStatsResult {
     if (!row) {
       return {
         topWin: { ae: 0, usd: 0 },

@@ -254,19 +262,22 @@ export class BclPnlService {
         WHERE ta.current_holdings > 0
       )
       SELECT
-        ss.top_win_ae,
-        ss.top_win_usd,
+        COALESCE(tw.top_win_ae, 0) AS top_win_ae,
+        COALESCE(tw.top_win_usd, 0) AS top_win_usd,
         ss.winning_sells,
         ss.total_sells,
         ss.avg_hold_secs,
         u.unrealized_ae,
         u.unrealized_usd
       FROM sell_stats ss
       CROSS JOIN unrealized u
+      LEFT JOIN top_win tw ON true
     `;
   }
 
-  private mapTradingStats(row: Record<string, any> | undefined): TradingStatsResult {
+  private mapTradingStats(
+    row: Record<string, any> | undefined,
+  ): TradingStatsResult {
     if (!row) {
       return {
         topWin: { ae: 0, usd: 0 },
@@ -895,7 +906,6 @@ export class BclPnlService {
     tokenPnls: Array<Record<string, any>>,
     isRangeBased: boolean,
   ): TokenPnlResult {
-
     const result: TokenPnlResult['pnls'] = {};
     let totalCostBasisAe = 0;
     let totalCostBasisUsd = 0;

@@ -895,7 +906,6 @@ export class BclPnlService {
     tokenPnls: Array<Record<string, any>>,
     isRangeBased: boolean,
   ): TokenPnlResult {
-
     const result: TokenPnlResult['pnls'] = {};
     let totalCostBasisAe = 0;
     let totalCostBasisUsd = 0;
@@ -975,7 +985,8 @@ export class BclPnlService {
         costBasisUsd = totalAmountSpentUsd;
 
         gainAe = totalAmountReceivedAe + currentValueAe - totalAmountSpentAe;
-        gainUsd = totalAmountReceivedUsd + currentValueUsd - totalAmountSpentUsd;
+        gainUsd =
+          totalAmountReceivedUsd + currentValueUsd - totalAmountSpentUsd;
       }
 
       totalCostBasisAe += costBasisAe;

@@ -975,7 +985,8 @@ export class BclPnlService {
         costBasisUsd = totalAmountSpentUsd;
 
         gainAe = totalAmountReceivedAe + currentValueAe - totalAmountSpentAe;
-        gainUsd = totalAmountReceivedUsd + currentValueUsd - totalAmountSpentUsd;
+        gainUsd =
+          totalAmountReceivedUsd + currentValueUsd - totalAmountSpentUsd;
       }
 
       totalCostBasisAe += costBasisAe;

diff --git a/src/account/services/portfolio.service.ts b/src/account/services/portfolio.service.ts
--- a/src/account/services/portfolio.service.ts
+++ b/src/account/services/portfolio.service.ts
@@ -378,7 +378,7 @@ export class PortfolioService {
     return data;
   }
 
-  private async resolveAccountAddress(address: string): Promise<string> {
+  async resolveAccountAddress(address: string): Promise<string> {
     if (!address || address.startsWith('ak_') || !address.includes('.')) {
       return address;
     }

@@ -378,7 +378,7 @@ export class PortfolioService {
     return data;
   }
 
-  private async resolveAccountAddress(address: string): Promise<string> {
+  async resolveAccountAddress(address: string): Promise<string> {
     if (!address || address.startsWith('ak_') || !address.includes('.')) {
       return address;
     }

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

address,
start,
end,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New stats endpoint missing address name resolution

Medium Severity

The new getPortfolioStats endpoint passes the raw address param directly to bclPnlService.calculateTradingStats. The existing getPortfolioHistory endpoint resolves .chain names to ak_ addresses via resolveAccountAddress() in PortfolioService, but the new endpoint bypasses this. Users passing a .chain name will get all-zero results because the SQL WHERE address = $1 will never match (transactions store ak_ addresses).

Fix in Cursor Fix in Web

@ifaouibadi ifaouibadi merged commit 6f0d78e into develop Mar 31, 2026
1 check passed
@ifaouibadi ifaouibadi deleted the features/account-pnls-calendar branch March 31, 2026 08:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant