Skip to content
4 changes: 4 additions & 0 deletions scripts/db-restore.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ docker cp $BACKUP_FILE $CONTAINER_NAME:/tmp/latest.sql.gz
echo "🗄️ Importing backup into database '$DB_NAME'..."
docker exec -i $CONTAINER_NAME bash -c "gunzip -c /tmp/latest.sql.gz | psql -U $DB_USER -d $DB_NAME"

# === RESET LOCAL PASSWORD (dumps may contain ALTER ROLE that overwrite it) ===
echo "🔑 Resetting local password for '$DB_USER'..."
docker exec -i $CONTAINER_NAME psql -U $DB_USER -d $DB_NAME -c "ALTER USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"

# === CLEANUP ===
echo "🧹 Cleaning up..."
docker exec -i $CONTAINER_NAME rm /tmp/latest.sql.gz
Expand Down
15 changes: 14 additions & 1 deletion src/account/controllers/accounts.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ describe('AccountsController', () => {
let profileReadService: {
getProfile: jest.Mock;
};
let portfolioService: {
resolveAccountAddress: jest.Mock;
};
let bclPnlService: {
calculateTradingStats: jest.Mock;
};

beforeEach(() => {
queryBuilder = createQueryBuilder();
Expand All @@ -40,10 +46,17 @@ describe('AccountsController', () => {
profileReadService = {
getProfile: jest.fn(),
};
portfolioService = {
resolveAccountAddress: jest.fn().mockImplementation((a) => a),
};
bclPnlService = {
calculateTradingStats: jest.fn(),
};

controller = new AccountsController(
accountRepository as any,
{} as any,
portfolioService as any,
bclPnlService as any,
accountService as any,
profileReadService as any,
);
Expand Down
38 changes: 38 additions & 0 deletions src/account/controllers/accounts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ import { paginate } from 'nestjs-typeorm-paginate';
import { Brackets, Repository } from 'typeorm';
import { Account } from '../entities/account.entity';
import { PortfolioService } from '../services/portfolio.service';
import { BclPnlService } from '../services/bcl-pnl.service';
import { AccountService } from '../services/account.service';
import { GetPortfolioHistoryQueryDto } from '../dto/get-portfolio-history-query.dto';
import { PortfolioHistorySnapshotDto } from '../dto/portfolio-history-response.dto';
import { TradingStatsQueryDto } from '../dto/trading-stats-query.dto';
import { TradingStatsResponseDto } from '../dto/trading-stats-response.dto';
import { ProfileReadService } from '@/profile/services/profile-read.service';
import { ProfileCache } from '@/profile/entities/profile-cache.entity';

Expand All @@ -39,6 +42,7 @@ export class AccountsController {
@InjectRepository(Account)
private readonly accountRepository: Repository<Account>,
private readonly portfolioService: PortfolioService,
private readonly bclPnlService: BclPnlService,
private readonly accountService: AccountService,
private readonly profileReadService: ProfileReadService,
) {
Expand Down Expand Up @@ -190,6 +194,40 @@ export class AccountsController {
});
}

// Portfolio stats endpoint - MUST come before :address route to avoid route conflict
@ApiOperation({ operationId: 'getPortfolioStats' })
@ApiParam({ name: 'address', type: 'string', description: 'Account address' })
@ApiOkResponse({ type: TradingStatsResponseDto })
@CacheTTL(60 * 10) // 10 minutes
@Get(':address/portfolio/stats')
async getPortfolioStats(
@Param('address') address: string,
@Query() query: TradingStatsQueryDto,
): Promise<TradingStatsResponseDto> {
const start = query.startDate
? 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(
resolvedAddress,
start,
end,
);

return {
top_win: stats.topWin,
unrealized_profit: stats.unrealizedProfit,
win_rate: stats.winRate,
avg_duration_seconds: stats.avgDurationSeconds,
total_trades: stats.totalTrades,
winning_trades: stats.winningTrades,
};
}

// single account - MUST come after more specific routes
@ApiOperation({ operationId: 'getAccount' })
@ApiParam({ name: 'address', type: 'string' })
Expand Down
26 changes: 26 additions & 0 deletions src/account/dto/trading-stats-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';

export class TradingStatsQueryDto {
@ApiProperty({
name: 'startDate',
type: 'string',
required: false,
description: 'Start date (ISO 8601, inclusive). Defaults to 30 days ago.',
example: '2026-01-01T00:00:00.000Z',
})
@IsOptional()
@IsString()
startDate?: string;

@ApiProperty({
name: 'endDate',
type: 'string',
required: false,
description: 'End date (ISO 8601, exclusive). Defaults to now.',
example: '2026-01-31T23:59:59.999Z',
})
@IsOptional()
@IsString()
endDate?: string;
}
43 changes: 43 additions & 0 deletions src/account/dto/trading-stats-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { PnlAmountDto } from './pnl-response.dto';

export class TradingStatsResponseDto {
@ApiProperty({
description: 'Largest single realized gain from any sell in the date range',
type: () => PnlAmountDto,
})
top_win: PnlAmountDto;

@ApiProperty({
description:
'Current unrealized profit across all tokens still held (all-time holdings, not date-filtered)',
type: () => PnlAmountDto,
})
unrealized_profit: PnlAmountDto;

@ApiProperty({
description:
'Percentage of sell transactions in the range that produced a positive gain',
example: 66.7,
})
win_rate: number;

@ApiProperty({
description:
'Average holding duration in seconds from first buy to sell (across sells in the range)',
example: 259200,
})
avg_duration_seconds: number;

@ApiProperty({
description: 'Total number of sell transactions in the date range',
example: 6,
})
total_trades: number;

@ApiProperty({
description: 'Number of sell transactions that produced a positive gain',
example: 4,
})
winning_trades: number;
}
Loading
Loading