refactor: centralize backend profile metadata lookups

This commit is contained in:
Saravana Achu Mac 2026-04-04 16:12:41 -07:00
parent ebaabaed47
commit 50defe1890
5 changed files with 121 additions and 12 deletions

View File

@ -1,6 +1,7 @@
import logger from '../utils/logger.js';
import { config } from '../config/index.js';
import { supabaseService } from './SupabaseService.js';
import { getTradeProfileCapital } from './profileRepository.js';
export interface CapitalLedgerRecord {
profile_id: string;
@ -39,7 +40,7 @@ export class CapitalLedger {
logger.error(`[CapitalLedger] ensureLedger aborted for ${profileId}: Supabase client unavailable (fail-closed).`);
return null;
}
const profileCapital = await supabaseService.getProfileCapital(profileId);
const profileCapital = await getTradeProfileCapital(profileId, supabaseService);
const allocation = toNumeric(allocatedCapital ?? profileCapital?.allocatedCapital ?? config.TOTAL_CAPITAL);
const { data, error } = await client

View File

@ -3,6 +3,7 @@ import { SignalDirection } from '../strategies/rules/types.js';
import logger from '../utils/logger.js';
import { supabaseService } from './SupabaseService.js';
import { config } from '../config/index.js';
import { getTradeProfileCapital } from './profileRepository.js';
const CAPITAL_WAIT_TIMEOUT_MS = 60_000;
const CAPITAL_WAIT_POLL_MS = 3_000;
@ -33,7 +34,7 @@ export class ManualTrader {
return Number(config.TOTAL_CAPITAL || 0);
}
const profileCapital = await supabaseService.getProfileCapital(profileId);
const profileCapital = await getTradeProfileCapital(profileId, supabaseService);
if (profileCapital?.allocatedCapital && profileCapital.allocatedCapital > 0) {
return profileCapital.allocatedCapital;
}

View File

@ -18,7 +18,9 @@ import {
deleteTradeProfileForUser,
ensureDefaultTradeProfileForUser,
getCurrentUserProfile,
getTradeProfileForUser,
listAllTradeProfiles,
listActiveTradeProfiles,
listTradeProfilesForUser,
saveCurrentUserProfile,
saveTradeProfileForUser,
@ -1252,13 +1254,14 @@ export class ApiServer {
});
this.app.get('/api/state', this.requireAuth, async (req, res) => {
const authUserId = (req as AuthenticatedRequest).authUserId;
const authReq = req as AuthenticatedRequest;
const authUserId = authReq.authUserId;
if (!authUserId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
this.state.uptime = Date.now() - this.startTime;
const isAdmin = await supabaseService.isAdmin(authUserId);
const isAdmin = await isTradingAdmin(authUserId, authReq.authRole);
const scopedState = this.getScopedState(authUserId, isAdmin);
res.json({
...scopedState,
@ -1267,14 +1270,15 @@ export class ApiServer {
});
this.app.get('/api/lifecycle/canonical', this.requireAuth, async (req, res) => {
const authUserId = (req as AuthenticatedRequest).authUserId;
const authReq = req as AuthenticatedRequest;
const authUserId = authReq.authUserId;
if (!authUserId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
try {
const isAdmin = await supabaseService.isAdmin(authUserId);
const isAdmin = await isTradingAdmin(authUserId, authReq.authRole);
const requestedProfileId = String(req.query.profileId || '').trim();
const splitByProfileQuery = String(req.query.splitByProfile || '').trim().toLowerCase();
const splitByProfile = splitByProfileQuery
@ -1288,8 +1292,8 @@ export class ApiServer {
);
const profileRows = isAdmin
? await supabaseService.getAllProfiles()
: await supabaseService.getProfilesForUser(authUserId);
? await listAllTradeProfiles(supabaseService)
: await listTradeProfilesForUser(authUserId, supabaseService);
if (requestedProfileId && !profileRows.some((row) => row.id === requestedProfileId)) {
res.status(403).json({ success: false, error: 'Forbidden: profile does not belong to scoped user context' });
@ -1889,7 +1893,8 @@ export class ApiServer {
});
this.app.post('/api/backtest/run', this.requireAuth, async (req, res) => {
const authUserId = (req as AuthenticatedRequest).authUserId;
const authReq = req as AuthenticatedRequest;
const authUserId = authReq.authUserId;
if (!authUserId) {
res.status(401).json({ success: false, error: 'Unauthorized' });
return;
@ -1902,7 +1907,7 @@ export class ApiServer {
return;
}
const isAdmin = await supabaseService.isAdmin(authUserId);
const isAdmin = await isTradingAdmin(authUserId, authReq.authRole);
if (!isAdmin && !config.BACKTEST_CUSTOMER_ENABLED) {
res.status(403).json({
success: false,
@ -1916,7 +1921,7 @@ export class ApiServer {
const profileId = String(body.profileId || '').trim();
let profileSettings: any = undefined;
if (profileId) {
profileSettings = await supabaseService.getProfileForBacktest(profileId, authUserId);
profileSettings = await getTradeProfileForUser(profileId, authUserId, supabaseService);
if (!profileSettings) {
res.status(404).json({ success: false, error: 'Backtest profile not found for current user' });
return;

View File

@ -35,6 +35,12 @@ export interface TradeProfileRecord {
updated_at?: string;
}
export interface TradeProfileCapitalSummary {
allocatedCapital: number;
isActive: boolean;
userId?: string;
}
interface TradeProfileDocument extends TradeProfileRecord {
productId: string;
type: 'trade_profile';
@ -197,6 +203,57 @@ async function listAllProfilesFromSupabase(legacyService?: LegacySupabaseService
}
}
async function getProfileFromCosmos(profileId: string): Promise<TradeProfileRecord | null> {
if (!isCosmosConfigured() || !profileId) {
return null;
}
const container = getContainer(PROFILE_CONTAINER);
const { resources } = await container.items.query<TradeProfileDocument>({
query: 'SELECT TOP 1 * FROM c WHERE c.productId = @productId AND c.type = @type AND c.id = @id',
parameters: [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'trade_profile' },
{ name: '@id', value: profileId },
],
}).fetchAll();
const resource = resources[0];
if (!resource) {
return null;
}
return normalizeProfile({
...resource,
created_at: resource.createdAt,
updated_at: resource.updatedAt,
});
}
async function getProfileFromSupabase(profileId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord | null> {
const client = legacyService?.getClient?.();
if (!client || !profileId) {
return null;
}
try {
const { data, error } = await client
.from('trade_profiles')
.select('id,user_id,name,allocated_capital,risk_per_trade_percent,symbols,is_active,strategy_config,created_at,updated_at')
.eq('id', profileId)
.maybeSingle();
if (error || !data) {
return null;
}
return normalizeProfile(data as TradeProfileRecord);
} catch (error) {
logger.warn(`[Profiles] Legacy profile lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`);
return null;
}
}
async function mirrorProfileToSupabase(profile: TradeProfileRecord, legacyService?: LegacySupabaseService): Promise<void> {
const client = legacyService?.getClient?.();
if (!client) return;
@ -263,6 +320,50 @@ export async function listAllTradeProfiles(legacyService?: LegacySupabaseService
return listAllProfilesFromSupabase(legacyService);
}
export async function listActiveTradeProfiles(legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
const profiles = await listAllTradeProfiles(legacyService);
return profiles.filter((profile) => Boolean(profile.is_active));
}
export async function getTradeProfileById(profileId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord | null> {
const normalizedId = String(profileId || '').trim();
if (!normalizedId) {
return null;
}
try {
const cosmosProfile = await getProfileFromCosmos(normalizedId);
if (cosmosProfile) {
return cosmosProfile;
}
} catch (error) {
logger.warn(`[Profiles] Cosmos profile lookup failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`);
}
return getProfileFromSupabase(normalizedId, legacyService);
}
export async function getTradeProfileCapital(profileId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileCapitalSummary | null> {
const profile = await getTradeProfileById(profileId, legacyService);
if (!profile) {
return null;
}
return {
allocatedCapital: Number(profile.allocated_capital || 0),
isActive: Boolean(profile.is_active),
userId: profile.user_id || undefined,
};
}
export async function getTradeProfileForUser(profileId: string, userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord | null> {
const profile = await getTradeProfileById(profileId, legacyService);
if (!profile || String(profile.user_id || '').trim() !== String(userId || '').trim()) {
return null;
}
return profile;
}
export async function ensureDefaultTradeProfileForUser(userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
const profiles = await listTradeProfilesForUser(userId, legacyService);
if (profiles.length > 0) {

View File

@ -4,6 +4,7 @@ import logger from '../utils/logger.js';
import { healthTracker } from './healthTracker.js';
import { observabilityService } from './observabilityService.js';
import { supabaseService } from './SupabaseService.js';
import { getTradeProfileCapital } from './profileRepository.js';
import type { TradeExecutor } from './TradeExecutor.js';
import { buildAlpacaSubTag } from '../utils/alpacaSubTag.js';
import { normalizeBotSymbolToken } from '../utils/botSymbolScope.js';
@ -275,7 +276,7 @@ export class ReconciliationParityHeartbeatService {
const throttleMs = Math.max(0, toNumber(config.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS || 600_000));
const requireSubTagAttribution = Boolean(config.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION);
const allowLegacyEntryAttribution = Boolean(config.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION);
const profileCapital = await supabaseService.getProfileCapital(profileId);
const profileCapital = await getTradeProfileCapital(profileId, supabaseService);
const allocatedCapitalUsd = Math.max(0, toNumber(profileCapital?.allocatedCapital));
let mismatchTrades = 0;