refactor: move runtime trading records to cosmos

This commit is contained in:
Saravana Achu Mac 2026-04-04 17:48:49 -07:00
parent e043f3c79d
commit 5d3be081ee
10 changed files with 1621 additions and 363 deletions

View File

@ -5,7 +5,6 @@ import { TradeExecutor } from './TradeExecutor.js';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import { SymbolMapper } from '../utils/symbolMapper.js'; import { SymbolMapper } from '../utils/symbolMapper.js';
import { IExchangeConnector } from '../connectors/types.js'; import { IExchangeConnector } from '../connectors/types.js';
import { supabaseService } from './SupabaseService.js';
import { healthTracker } from './healthTracker.js'; import { healthTracker } from './healthTracker.js';
import { import {
getProfileConsecutiveLosses, getProfileConsecutiveLosses,
@ -334,7 +333,7 @@ export class AutoTrader {
const maxDailyLossUsd = Number(riskLimits.maxDailyLossUsd); const maxDailyLossUsd = Number(riskLimits.maxDailyLossUsd);
if (Number.isFinite(maxDailyLossUsd) && maxDailyLossUsd > 0) { if (Number.isFinite(maxDailyLossUsd) && maxDailyLossUsd > 0) {
const dailyLossUsd = await getProfileDailyLossUsd(profileId, supabaseService); const dailyLossUsd = await getProfileDailyLossUsd(profileId);
if (dailyLossUsd >= maxDailyLossUsd) { if (dailyLossUsd >= maxDailyLossUsd) {
return { return {
allowed: false, allowed: false,
@ -345,7 +344,7 @@ export class AutoTrader {
const dailyProfitTargetUsd = Number(riskLimits.dailyProfitTargetUsd); const dailyProfitTargetUsd = Number(riskLimits.dailyProfitTargetUsd);
if (Number.isFinite(dailyProfitTargetUsd) && dailyProfitTargetUsd > 0) { if (Number.isFinite(dailyProfitTargetUsd) && dailyProfitTargetUsd > 0) {
const dailyNetPnl = await getProfileDailyNetPnlUsd(profileId, supabaseService); const dailyNetPnl = await getProfileDailyNetPnlUsd(profileId);
if (dailyNetPnl >= dailyProfitTargetUsd) { if (dailyNetPnl >= dailyProfitTargetUsd) {
return { return {
allowed: false, allowed: false,
@ -356,7 +355,7 @@ export class AutoTrader {
const maxConsecutiveLosses = Number(riskLimits.maxConsecutiveLosses); const maxConsecutiveLosses = Number(riskLimits.maxConsecutiveLosses);
if (Number.isFinite(maxConsecutiveLosses) && maxConsecutiveLosses > 0) { if (Number.isFinite(maxConsecutiveLosses) && maxConsecutiveLosses > 0) {
const consecutiveLosses = await getProfileConsecutiveLosses(profileId, 100, supabaseService); const consecutiveLosses = await getProfileConsecutiveLosses(profileId, 100);
if (consecutiveLosses >= maxConsecutiveLosses) { if (consecutiveLosses >= maxConsecutiveLosses) {
return { return {
allowed: false, allowed: false,

View File

@ -1845,7 +1845,7 @@ export class ApiServer {
} }
try { try {
const entries = await listManualEntriesForUser(authUserId, supabaseService); const entries = await listManualEntriesForUser(authUserId);
res.json({ entries }); res.json({ entries });
} catch (error: any) { } catch (error: any) {
res.status(500).json({ error: `Failed to load manual entries: ${error.message}` }); res.status(500).json({ error: `Failed to load manual entries: ${error.message}` });
@ -1867,16 +1867,14 @@ export class ApiServer {
const orderLimit = Math.max(1, Math.min(5000, parseInt(String(req.query.limit || '5000'), 10) || 5000)); const orderLimit = Math.max(1, Math.min(5000, parseInt(String(req.query.limit || '5000'), 10) || 5000));
const [entries, orders, historyTradeKeys, profiles] = await Promise.all([ const [entries, orders, historyTradeKeys, profiles] = await Promise.all([
listManualEntriesForUser(authUserId, supabaseService), listManualEntriesForUser(authUserId),
listRecentOrders({ listRecentOrders({
userId: wantsAll ? undefined : authUserId, userId: wantsAll ? undefined : authUserId,
limit: orderLimit, limit: orderLimit
legacyService: supabaseService
}), }),
listRecentTradeHistoryKeys({ listRecentTradeHistoryKeys({
userId: wantsAll ? undefined : authUserId, userId: wantsAll ? undefined : authUserId,
limit: orderLimit, limit: orderLimit
legacyService: supabaseService
}), }),
wantsAll wantsAll
? listAllTradeProfiles(supabaseService) ? listAllTradeProfiles(supabaseService)
@ -1910,8 +1908,7 @@ export class ApiServer {
const rows = await listRecentTradeHistory({ const rows = await listRecentTradeHistory({
userId: wantsAll ? undefined : authUserId, userId: wantsAll ? undefined : authUserId,
limit, limit
legacyService: supabaseService
}); });
res.json({ rows }); res.json({ rows });
@ -1928,7 +1925,7 @@ export class ApiServer {
} }
try { try {
const entry = await saveManualEntryForUser(authUserId, req.body || {}, supabaseService); const entry = await saveManualEntryForUser(authUserId, req.body || {});
res.status(201).json({ entry }); res.status(201).json({ entry });
} catch (error: any) { } catch (error: any) {
res.status(400).json({ error: `Failed to save manual entry: ${error.message}` }); res.status(400).json({ error: `Failed to save manual entry: ${error.message}` });
@ -1946,7 +1943,7 @@ export class ApiServer {
const entry = await saveManualEntryForUser(authUserId, { const entry = await saveManualEntryForUser(authUserId, {
...(req.body || {}), ...(req.body || {}),
stock_instance_id: String(req.params.id || '').trim() stock_instance_id: String(req.params.id || '').trim()
}, supabaseService); });
res.json({ entry }); res.json({ entry });
} catch (error: any) { } catch (error: any) {
res.status(400).json({ error: `Failed to update manual entry: ${error.message}` }); res.status(400).json({ error: `Failed to update manual entry: ${error.message}` });
@ -1961,7 +1958,7 @@ export class ApiServer {
} }
try { try {
await deleteManualEntryForUser(authUserId, String(req.params.id || '').trim(), supabaseService); await deleteManualEntryForUser(authUserId, String(req.params.id || '').trim());
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
res.status(400).json({ error: `Failed to delete manual entry: ${error.message}` }); res.status(400).json({ error: `Failed to delete manual entry: ${error.message}` });

View File

@ -1,8 +1,16 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { config } from '../config/index.js';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import type { supabaseService } from './SupabaseService.js'; import {
MANUAL_ENTRY_CONTAINER,
type LegacySupabaseService = typeof supabaseService; buildBaseDocument,
buildDocId,
clampNumber,
queryDocuments,
toOptionalString,
upsertDocument
} from './tradingRecordStore.js';
import { logTransaction } from './runtimeOrderRepository.js';
export interface ManualEntryRecord { export interface ManualEntryRecord {
stock_instance_id: string; stock_instance_id: string;
@ -25,13 +33,13 @@ export interface ManualEntryRecord {
drop_threshold_for_buy?: number | null; drop_threshold_for_buy?: number | null;
} }
function getClient(legacyService?: LegacySupabaseService) { type ManualEntryDocument = ManualEntryRecord & {
const client = legacyService?.getClient?.(); id: string;
if (!client) { productId: string;
throw new Error('Manual entry store is not configured'); type: 'manual_entry';
} created_at: string;
return client; updated_at: string;
} };
function normalizeNullableNumber(value: unknown): number | null | undefined { function normalizeNullableNumber(value: unknown): number | null | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined;
@ -70,119 +78,93 @@ function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, exist
}; };
} }
async function insertHistoryRecord(userId: string, entry: ManualEntryRecord, reason: 'Manual Log' | 'Manual Close', legacyService?: LegacySupabaseService): Promise<void> { async function listManualEntryDocuments(userId: string): Promise<ManualEntryDocument[]> {
const client = getClient(legacyService); const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type AND c.user_id = @userId ORDER BY c.created_at DESC';
const entryPrice = Number(entry.buy_price || 0); return await queryDocuments<ManualEntryDocument>(MANUAL_ENTRY_CONTAINER, query, [
const exitPrice = Number(entry.sell_price || 0); { name: '@productId', value: config.PRODUCT_ID },
const size = Number(entry.quantity || 0); { name: '@type', value: 'manual_entry' },
{ name: '@userId', value: userId },
]);
}
async function findManualEntryDocument(userId: string, entryId: string): Promise<ManualEntryDocument | null> {
const rows = await queryDocuments<ManualEntryDocument>(MANUAL_ENTRY_CONTAINER, 'SELECT TOP 1 * FROM c WHERE c.productId = @productId AND c.type = @type AND c.user_id = @userId AND c.stock_instance_id = @entryId', [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'manual_entry' },
{ name: '@userId', value: userId },
{ name: '@entryId', value: entryId },
]);
return rows[0] || null;
}
async function insertHistoryRecord(userId: string, entry: ManualEntryRecord, reason: 'Manual Log' | 'Manual Close'): Promise<void> {
const entryPrice = clampNumber(entry.buy_price);
const exitPrice = clampNumber(entry.sell_price);
const size = clampNumber(entry.quantity);
if (!(entryPrice > 0) || !(exitPrice > 0) || !(size > 0)) { if (!(entryPrice > 0) || !(exitPrice > 0) || !(size > 0)) {
return; return;
} }
const pnl = (exitPrice - entryPrice) * size; const pnl = (exitPrice - entryPrice) * size;
const pnlPercent = entryPrice > 0 ? ((exitPrice - entryPrice) / entryPrice) * 100 : 0; const pnlPercent = entryPrice > 0 ? ((exitPrice - entryPrice) / entryPrice) * 100 : 0;
await logTransaction({
const { error } = await client user_id: userId,
.from('trade_history') symbol: entry.symbol,
.insert([{ side: 'BUY',
user_id: userId, entry_price: entryPrice,
symbol: entry.symbol, exit_price: exitPrice,
side: 'BUY', size,
entry_price: entryPrice, pnl,
exit_price: exitPrice, pnl_percent: pnlPercent,
size, reason,
pnl, timestamp: Date.now(),
pnl_percent: pnlPercent, source: 'MANUAL'
source: 'MANUAL', });
reason,
timestamp: Date.now()
}]);
if (error) {
logger.warn(`[ManualEntries] Failed to insert history row: ${error.message}`);
}
} }
export async function listManualEntriesForUser(userId: string, legacyService?: LegacySupabaseService): Promise<ManualEntryRecord[]> { export async function listManualEntriesForUser(userId: string): Promise<ManualEntryRecord[]> {
const client = getClient(legacyService); const rows = await listManualEntryDocuments(userId);
const { data, error } = await client return rows as ManualEntryRecord[];
.from('stocks')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) {
throw new Error(error.message);
}
return Array.isArray(data) ? data as ManualEntryRecord[] : [];
} }
export async function saveManualEntryForUser( export async function saveManualEntryForUser(userId: string, input: Partial<ManualEntryRecord>): Promise<ManualEntryRecord> {
userId: string,
input: Partial<ManualEntryRecord>,
legacyService?: LegacySupabaseService
): Promise<ManualEntryRecord> {
const client = getClient(legacyService);
const entryId = String(input.stock_instance_id || '').trim(); const entryId = String(input.stock_instance_id || '').trim();
let existing: ManualEntryRecord | null = null; const existing = entryId ? await findManualEntryDocument(userId, entryId) : null;
if (entryId) {
const { data, error } = await client
.from('stocks')
.select('*')
.eq('stock_instance_id', entryId)
.eq('user_id', userId)
.maybeSingle();
if (error) {
throw new Error(error.message);
}
existing = data as ManualEntryRecord | null;
}
const normalized = normalizeEntry(userId, input, existing); const normalized = normalizeEntry(userId, input, existing);
if (!normalized.symbol) { if (!normalized.symbol) {
throw new Error('Symbol is required'); throw new Error('Symbol is required');
} }
const payload = buildBaseDocument('manual_entry', {
...normalized,
stock_instance_id: normalized.stock_instance_id,
}, existing?.id || buildDocId('manual_entry', userId, normalized.stock_instance_id));
await upsertDocument<ManualEntryDocument>(MANUAL_ENTRY_CONTAINER, payload as ManualEntryDocument);
if (existing) { if (existing) {
const { error } = await client
.from('stocks')
.update(normalized)
.eq('stock_instance_id', normalized.stock_instance_id)
.eq('user_id', userId);
if (error) {
throw new Error(error.message);
}
if (normalized.sell_price && !existing.sell_price && normalized.buy_price) { if (normalized.sell_price && !existing.sell_price && normalized.buy_price) {
await insertHistoryRecord(userId, normalized, 'Manual Close', legacyService); await insertHistoryRecord(userId, normalized, 'Manual Close');
} }
return normalized; } else if (normalized.sell_price && normalized.buy_price) {
await insertHistoryRecord(userId, normalized, 'Manual Log');
} }
const { error } = await client
.from('stocks')
.insert([normalized]);
if (error) {
throw new Error(error.message);
}
if (normalized.sell_price && normalized.buy_price) {
await insertHistoryRecord(userId, normalized, 'Manual Log', legacyService);
}
return normalized; return normalized;
} }
export async function deleteManualEntryForUser(userId: string, entryId: string, legacyService?: LegacySupabaseService): Promise<void> { export async function deleteManualEntryForUser(userId: string, entryId: string): Promise<void> {
const client = getClient(legacyService); const existing = await findManualEntryDocument(userId, entryId);
const { error } = await client if (!existing) {
.from('stocks') return;
.delete()
.eq('stock_instance_id', entryId)
.eq('user_id', userId);
if (error) {
throw new Error(error.message);
} }
const tombstone = {
...existing,
active: false,
status: 'deleted',
updated_at: new Date().toISOString(),
deleted_at: new Date().toISOString(),
};
await upsertDocument<ManualEntryDocument>(MANUAL_ENTRY_CONTAINER, tombstone);
logger.info(`[ManualEntries] Marked ${toOptionalString(entryId) || 'unknown'} deleted in Cosmos`);
} }

View File

@ -1,56 +1,51 @@
import { config } from '../config/index.js';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import type { supabaseService } from './SupabaseService.js'; import { ORDER_CONTAINER, queryDocuments } from './tradingRecordStore.js';
type LegacySupabaseService = typeof supabaseService; type OrderActivityDocument = {
id?: string;
function getClient(legacyService?: LegacySupabaseService) { order_id?: string | null;
return legacyService?.getClient?.() ?? null; user_id?: string | null;
} profile_id?: string | null;
symbol?: string | null;
type?: string | null;
side?: string | null;
qty?: number | string | null;
quantity?: number | string | null;
price?: number | string | null;
status?: string | null;
timestamp?: number | string | null;
filled_at?: string | null;
created_at?: string | null;
trade_id?: string | null;
action?: string | null;
source?: string | null;
stop_loss?: number | string | null;
take_profit?: number | string | null;
sub_tag?: string | null;
};
export async function listRecentOrders(options: { export async function listRecentOrders(options: {
userId?: string; userId?: string;
limit?: number; limit?: number;
legacyService?: LegacySupabaseService;
}): Promise<any[]> { }): Promise<any[]> {
const client = getClient(options.legacyService); try {
if (!client) return []; const filters = ['c.productId = @productId', 'c.type = @type'];
const parameters: Array<{ name: string; value: unknown }> = [
const orderColumnsV3 = 'id,order_id,user_id,profile_id,symbol,type,side,qty,quantity,price,status,timestamp,filled_at,created_at,trade_id,action,source,stop_loss,take_profit,sub_tag'; { name: '@productId', value: config.PRODUCT_ID },
const orderColumnsV2 = 'id,order_id,user_id,profile_id,symbol,type,side,qty,quantity,price,status,timestamp,filled_at,created_at,trade_id,action,source,stop_loss,take_profit'; { name: '@type', value: 'trade_order' },
const orderColumnsLegacy = 'id,order_id,user_id,profile_id,symbol,type,side,qty,price,status,timestamp,created_at'; ];
const limit = Math.max(1, Math.min(5000, Math.floor(Number(options.limit || 5000))));
const runQuery = async (columns: string) => {
let query = client
.from('orders')
.select(columns)
.order('created_at', { ascending: false })
.limit(limit);
if (options.userId) { if (options.userId) {
query = query.eq('user_id', options.userId); filters.push('c.user_id = @userId');
parameters.push({ name: '@userId', value: options.userId });
} }
return query; const limit = Math.max(1, Math.min(5000, Math.floor(Number(options.limit || 5000))));
}; const query = `SELECT TOP ${limit} * FROM c WHERE ${filters.join(' AND ')} ORDER BY c.created_at DESC`;
return await queryDocuments<OrderActivityDocument>(ORDER_CONTAINER, query, parameters);
try {
const { data: v3Data, error: v3Error } = await runQuery(orderColumnsV3);
if (!v3Error) return v3Data || [];
logger.warn(`[OrderActivityRepo] V3 order query failed, falling back to v2 columns: ${v3Error.message}`);
const { data: v2Data, error: v2Error } = await runQuery(orderColumnsV2);
if (!v2Error) return v2Data || [];
logger.warn(`[OrderActivityRepo] V2 order query failed, falling back to legacy columns: ${v2Error.message}`);
const { data: legacyData, error: legacyError } = await runQuery(orderColumnsLegacy);
if (legacyError) {
logger.error(`[OrderActivityRepo] Legacy order query failed: ${legacyError.message}`);
return [];
}
return legacyData || [];
} catch (error: any) { } catch (error: any) {
logger.error(`[OrderActivityRepo] Recent order lookup unexpected error: ${error.message}`); logger.error(`[OrderActivityRepo] Recent order lookup failed: ${error.message}`);
return []; return [];
} }
} }

View File

@ -2,9 +2,9 @@ import { config } from '../config/index.js';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import { observabilityService } from './observabilityService.js'; import { observabilityService } from './observabilityService.js';
import { import {
type ReconciliationSubTagRepairSummary, type ReconciliationSubTagRepairSummary
supabaseService
} from './SupabaseService.js'; } from './SupabaseService.js';
import { repairMissingSubTagsForProfile } from './runtimeOrderRepository.js';
export interface ReconciliationSubTagRepairContext { export interface ReconciliationSubTagRepairContext {
profileId: string; profileId: string;
@ -36,7 +36,7 @@ export class ReconciliationSubTagRepairService {
const lookbackHours = Math.max(1, Math.floor(Number(config.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS || 720))); const lookbackHours = Math.max(1, Math.floor(Number(config.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS || 720)));
const maxUpdatesPerProfile = Math.max(1, Math.floor(Number(config.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE || 500))); const maxUpdatesPerProfile = Math.max(1, Math.floor(Number(config.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE || 500)));
const result = await supabaseService.repairMissingSubTagsForProfile({ const result = await repairMissingSubTagsForProfile({
profileId: ctx.profileId, profileId: ctx.profileId,
lookbackHours, lookbackHours,
maxRows: maxUpdatesPerProfile, maxRows: maxUpdatesPerProfile,

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import type { supabaseService } from './SupabaseService.js'; import { TRADE_HISTORY_CONTAINER, clampNumber, queryDocuments } from './tradingRecordStore.js';
import { config } from '../config/index.js';
type LegacySupabaseService = typeof supabaseService;
export interface TradeHistoryKeyRecord { export interface TradeHistoryKeyRecord {
trade_id: string; trade_id: string;
@ -25,79 +24,78 @@ export interface TradeHistoryRow {
source?: string; source?: string;
} }
type TradeHistoryDocument = TradeHistoryRow & {
productId: string;
type: 'trade_history';
user_id?: string;
};
const startOfCurrentDayUtc = (): string => { const startOfCurrentDayUtc = (): string => {
const now = new Date(); const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString(); return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString();
}; };
function getClient(legacyService?: LegacySupabaseService) { async function listTradeHistoryDocuments(options: {
return legacyService?.getClient?.() ?? null; userId?: string;
profileId?: string;
limit?: number;
}): Promise<TradeHistoryDocument[]> {
const filters = ['c.productId = @productId', 'c.type = @type'];
const parameters: Array<{ name: string; value: unknown }> = [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'trade_history' },
];
if (options.userId) {
filters.push('c.user_id = @userId');
parameters.push({ name: '@userId', value: options.userId });
}
if (options.profileId) {
filters.push('c.profile_id = @profileId');
parameters.push({ name: '@profileId', value: options.profileId });
}
const limit = Math.max(1, Math.min(5000, Math.floor(Number(options.limit || 5000))));
const query = `SELECT TOP ${limit} * FROM c WHERE ${filters.join(' AND ')} ORDER BY c.created_at DESC`;
return await queryDocuments<TradeHistoryDocument>(TRADE_HISTORY_CONTAINER, query, parameters);
} }
export async function getProfileDailyNetPnlUsd(profileId: string, legacyService?: LegacySupabaseService): Promise<number> { export async function getProfileDailyNetPnlUsd(profileId: string): Promise<number> {
const client = getClient(legacyService); if (!profileId) return 0;
if (!client || !profileId) return 0;
try { try {
const { data, error } = await client const rows = await listTradeHistoryDocuments({ profileId, limit: 5000 });
.from('trade_history') const todayIso = startOfCurrentDayUtc();
.select('pnl, created_at') return rows.reduce((sum, row) => {
.eq('profile_id', profileId) if (String(row.created_at || '') < todayIso) return sum;
.gte('created_at', startOfCurrentDayUtc()) return sum + clampNumber(row.pnl);
.order('created_at', { ascending: false })
.limit(5000);
if (error) {
logger.error(`[TradeHistoryRepo] Daily net PnL lookup failed for ${profileId}: ${error.message}`);
return 0;
}
return (data || []).reduce((sum: number, row: any) => {
const pnl = Number(row?.pnl || 0);
return Number.isFinite(pnl) ? sum + pnl : sum;
}, 0); }, 0);
} catch (error: any) { } catch (error: any) {
logger.error(`[TradeHistoryRepo] Daily net PnL unexpected error for ${profileId}: ${error.message}`); logger.error(`[TradeHistoryRepo] Daily net PnL lookup failed for ${profileId}: ${error.message}`);
return 0; return 0;
} }
} }
export async function getProfileDailyLossUsd(profileId: string, legacyService?: LegacySupabaseService): Promise<number> { export async function getProfileDailyLossUsd(profileId: string): Promise<number> {
const netPnl = await getProfileDailyNetPnlUsd(profileId, legacyService); const netPnl = await getProfileDailyNetPnlUsd(profileId);
return netPnl < 0 ? Math.abs(netPnl) : 0; return netPnl < 0 ? Math.abs(netPnl) : 0;
} }
export async function getProfileConsecutiveLosses( export async function getProfileConsecutiveLosses(profileId: string, lookback: number = 100): Promise<number> {
profileId: string, if (!profileId) return 0;
lookback: number = 100,
legacyService?: LegacySupabaseService
): Promise<number> {
const client = getClient(legacyService);
if (!client || !profileId) return 0;
try { try {
const cappedLookback = Math.max(1, Math.min(500, Math.floor(lookback))); const rows = await listTradeHistoryDocuments({
const { data, error } = await client profileId,
.from('trade_history') limit: Math.max(1, Math.min(500, Math.floor(lookback)))
.select('pnl, created_at') });
.eq('profile_id', profileId)
.order('created_at', { ascending: false })
.limit(cappedLookback);
if (error) {
logger.error(`[TradeHistoryRepo] Consecutive loss lookup failed for ${profileId}: ${error.message}`);
return 0;
}
let consecutiveLosses = 0; let consecutiveLosses = 0;
for (const row of data || []) { for (const row of rows) {
const pnl = Number((row as any)?.pnl || 0); const pnl = clampNumber(row.pnl);
if (!Number.isFinite(pnl) || pnl >= 0) break; if (pnl >= 0) break;
consecutiveLosses += 1; consecutiveLosses += 1;
} }
return consecutiveLosses; return consecutiveLosses;
} catch (error: any) { } catch (error: any) {
logger.error(`[TradeHistoryRepo] Consecutive loss unexpected error for ${profileId}: ${error.message}`); logger.error(`[TradeHistoryRepo] Consecutive loss lookup failed for ${profileId}: ${error.message}`);
return 0; return 0;
} }
} }
@ -105,34 +103,17 @@ export async function getProfileConsecutiveLosses(
export async function listRecentTradeHistoryKeys(options: { export async function listRecentTradeHistoryKeys(options: {
userId?: string; userId?: string;
limit?: number; limit?: number;
legacyService?: LegacySupabaseService;
}): Promise<TradeHistoryKeyRecord[]> { }): Promise<TradeHistoryKeyRecord[]> {
const client = getClient(options.legacyService);
if (!client) return [];
try { try {
let query = client const rows = await listTradeHistoryDocuments(options);
.from('trade_history') return rows
.select('trade_id,profile_id') .map((row) => ({
.order('created_at', { ascending: false }) trade_id: String(row.trade_id || '').trim(),
.limit(Math.max(1, Math.min(5000, Math.floor(Number(options.limit || 5000))))); profile_id: row.profile_id ? String(row.profile_id) : null,
}))
if (options.userId) { .filter((row) => Boolean(row.trade_id));
query = query.eq('user_id', options.userId);
}
const { data, error } = await query;
if (error) {
logger.error(`[TradeHistoryRepo] Recent trade key lookup failed: ${error.message}`);
return [];
}
return (data || []).map((row: any) => ({
trade_id: String(row?.trade_id || '').trim(),
profile_id: row?.profile_id ? String(row.profile_id) : null
})).filter((row) => Boolean(row.trade_id));
} catch (error: any) { } catch (error: any) {
logger.error(`[TradeHistoryRepo] Recent trade key unexpected error: ${error.message}`); logger.error(`[TradeHistoryRepo] Recent trade key lookup failed: ${error.message}`);
return []; return [];
} }
} }
@ -140,31 +121,12 @@ export async function listRecentTradeHistoryKeys(options: {
export async function listRecentTradeHistory(options: { export async function listRecentTradeHistory(options: {
userId?: string; userId?: string;
limit?: number; limit?: number;
legacyService?: LegacySupabaseService;
}): Promise<TradeHistoryRow[]> { }): Promise<TradeHistoryRow[]> {
const client = getClient(options.legacyService);
if (!client) return [];
try { try {
let query = client const rows = await listTradeHistoryDocuments(options);
.from('trade_history') return rows as TradeHistoryRow[];
.select('id,timestamp,symbol,side,size,entry_price,exit_price,pnl,pnl_percent,reason,profile_id,created_at,trade_id,source')
.order('created_at', { ascending: false })
.limit(Math.max(1, Math.min(5000, Math.floor(Number(options.limit || 5000)))));
if (options.userId) {
query = query.eq('user_id', options.userId);
}
const { data, error } = await query;
if (error) {
logger.error(`[TradeHistoryRepo] Recent trade history lookup failed: ${error.message}`);
return [];
}
return (data || []) as TradeHistoryRow[];
} catch (error: any) { } catch (error: any) {
logger.error(`[TradeHistoryRepo] Recent trade history unexpected error: ${error.message}`); logger.error(`[TradeHistoryRepo] Recent trade history lookup failed: ${error.message}`);
return []; return [];
} }
} }

View File

@ -0,0 +1,85 @@
import { getContainer } from '@bytelyst/cosmos';
import { randomUUID } from 'node:crypto';
import { config } from '../config/index.js';
export const ORDER_CONTAINER = 'trade_orders';
export const TRADE_HISTORY_CONTAINER = 'trade_history';
export const MANUAL_ENTRY_CONTAINER = 'manual_entries';
export const RECONCILIATION_AUDIT_CONTAINER = 'reconciliation_backfill_audit';
export interface TradingRecordDocument {
id: string;
productId: string;
type: string;
created_at: string;
updated_at: string;
}
export function isCosmosConfigured(): boolean {
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY && config.COSMOS_DATABASE);
}
export function requireCosmosConfigured(): void {
if (!isCosmosConfigured()) {
throw new Error('Cosmos DB is not configured for trading record persistence');
}
}
export function nowIso(): string {
return new Date().toISOString();
}
export function clampNumber(value: unknown, fallback: number = 0): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function toOptionalString(value: unknown): string | undefined {
const text = String(value || '').trim();
return text || undefined;
}
export function toOptionalNumber(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
export function buildDocId(...parts: Array<unknown>): string {
const normalized = parts
.map((part) => String(part || '').trim())
.filter(Boolean)
.join('::');
return normalized || randomUUID();
}
export async function queryDocuments<T>(containerName: string, query: string, parameters: Array<{ name: string; value: unknown }>): Promise<T[]> {
requireCosmosConfigured();
const container = getContainer(containerName);
const { resources } = await container.items.query<T>({ query, parameters: parameters as any }).fetchAll();
return resources;
}
export async function upsertDocument<T extends TradingRecordDocument>(containerName: string, document: T): Promise<T> {
requireCosmosConfigured();
const container = getContainer(containerName);
const { resource } = await container.items.upsert<T>(document);
return (resource || document) as T;
}
export async function deleteDocument(containerName: string, id: string, partitionKey?: string): Promise<void> {
requireCosmosConfigured();
const container = getContainer(containerName);
await container.item(id, partitionKey || id).delete();
}
export function buildBaseDocument(type: string, payload: Record<string, unknown>, id?: string): TradingRecordDocument {
const timestamp = nowIso();
return {
id: id || buildDocId(type, payload['profile_id'], payload['order_id'], payload['trade_id'], payload['user_id'], randomUUID()),
productId: config.PRODUCT_ID,
type,
created_at: String(payload['created_at'] || timestamp),
updated_at: String(payload['updated_at'] || timestamp),
...payload,
};
}

View File

@ -8,7 +8,7 @@ It covers:
- local development setup - local development setup
- verification and CI expectations - verification and CI expectations
- staged cutover from legacy repos - staged rollout of the new monorepo deployment
- rollback rules - rollback rules
- release go/no-go checks - release go/no-go checks
- post-cutover monitoring - post-cutover monitoring
@ -25,7 +25,6 @@ It covers:
- access to: - access to:
- platform-service - platform-service
- Azure Cosmos DB - Azure Cosmos DB
- optional legacy Supabase project during migration
### Workspace bootstrap ### Workspace bootstrap
@ -70,18 +69,11 @@ pnpm --filter @bytelyst/trading-mobile dev
- `COSMOS_KEY` - `COSMOS_KEY`
- `COSMOS_DATABASE` - `COSMOS_DATABASE`
### Transitional legacy migration support
- `SUPABASE_URL`
- `SUPABASE_KEY`
- `SUPABASE_JWT_ISSUER`
- `SUPABASE_JWT_AUDIENCE`
Rule: Rule:
- platform-service and Cosmos are the target system - platform-service and Cosmos are the only supported production systems for this repo
- Supabase remains transitional only where trading persistence has not yet been migrated - legacy repos may still be consulted as code references, but they are not runtime dependencies
- trading user profiles, dynamic config, trading controls, snapshots, capital ledgers, and strategy presets now have Cosmos-backed authority paths - trading user profiles, dynamic config, trading controls, snapshots, capital ledgers, and strategy presets already use Cosmos-backed authority paths
## Verification Standard ## Verification Standard
@ -128,10 +120,9 @@ pnpm lint
### Backend cutover ### Backend cutover
- deploy backend with platform JWT support and Cosmos-backed trading controls enabled - deploy backend with platform JWT support and Cosmos-backed control-plane and execution persistence enabled
- allow legacy Supabase reads only for controlled migration seeding where a Cosmos-native repository is not complete yet
- confirm runtime control reads/writes work through backend APIs - confirm runtime control reads/writes work through backend APIs
- confirm `dynamic_config` and trading-control containers are readable and writable - confirm `dynamic_config`, trading-control, order, trade-history, and manual-entry containers are readable and writable
- confirm unauthorized requests are rejected and tenant-scoped reads are enforced - confirm unauthorized requests are rejected and tenant-scoped reads are enforced
### Web cutover ### Web cutover
@ -179,14 +170,14 @@ Release is `go` only if all of the following are true:
- `pnpm smoke:release` passes - `pnpm smoke:release` passes
- platform-service auth is reachable from web and mobile - platform-service auth is reachable from web and mobile
- Cosmos control-plane reads and writes succeed - Cosmos control-plane reads and writes succeed
- Cosmos execution-data reads and writes succeed
- kill-switch and maintenance behavior are validated on web and mobile - kill-switch and maintenance behavior are validated on web and mobile
- backend tenant isolation checks are green - backend tenant isolation checks are green
- operator-safe mobile interventions are limited to approved actions only - operator-safe mobile interventions are limited to approved actions only
- known migration-only legacy dependencies are documented - no legacy runtime data dependency remains in critical public flows
Release is `no-go` if any of the following are true: Release is `no-go` if any of the following are true:
- Supabase fallback is still required for a critical public flow that has no monitored contingency
- auth source of truth is ambiguous in production - auth source of truth is ambiguous in production
- admin/runtime-control actions are not fully audited - admin/runtime-control actions are not fully audited
- rollback owner or rollback commands are unclear - rollback owner or rollback commands are unclear
@ -233,8 +224,9 @@ Manual mobile release smoke is still required before broad rollout:
## Known Remaining Gaps ## Known Remaining Gaps
- full trading data-plane migration away from legacy Supabase is not complete - Cosmos-only execution persistence is now in place for the main backend runtime paths, but dormant legacy code and one-off reference scripts still need cleanup
- web still carries some legacy compatibility layers around auth/profile bootstrap - web still carries some compatibility layers around auth/profile bootstrap
- root `pnpm verify` is currently blocked by a web Vitest localStorage harness issue that needs its own cleanup pass
- mobile does not yet include push notification infrastructure - mobile does not yet include push notification infrastructure
- feature-flag ownership and correlation-ID propagation are not fully standardized yet - feature-flag ownership and correlation-ID propagation are not fully standardized yet

View File

@ -8,7 +8,8 @@ It assumes:
- `learning_ai_fastgap` is the structural reference monorepo - `learning_ai_fastgap` is the structural reference monorepo
- `learning_ai_common_plat` is the shared ecosystem dependency source - `learning_ai_common_plat` is the shared ecosystem dependency source
- `bytelyst-trading-dashboard-web`, `bytelyst-trading-bot-service`, and `bytelyst-trading-dashboard-mob` are migration inputs, not the target architecture - `bytelyst-trading-dashboard-web`, `bytelyst-trading-bot-service`, and `bytelyst-trading-dashboard-mob` are code-reference inputs, not the target architecture
- `learning_ai_invt_trdg` is a greenfield deployment with Cosmos DB as the only supported target persistence system
## 2. Status Model ## 2. Status Model
@ -30,21 +31,23 @@ It assumes:
- [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates - [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates
- [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, normalized backend URL resolution, and common-platform-native session handling - [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, normalized backend URL resolution, and common-platform-native session handling
- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, platform-service auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, secure session storage with invalidation handling, and explicit degraded/offline status surfacing - [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, platform-service auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, secure session storage with invalidation handling, and explicit degraded/offline status surfacing
- [x] Backend now accepts common-platform JWTs with legacy Supabase fallback and persists global trading-control state through Cosmos-backed control storage - [x] Backend now accepts common-platform JWTs and persists global trading-control state through Cosmos-backed control storage
- [x] Dynamic config now flows through backend control-plane APIs with Cosmos-first storage and one-time legacy seeding during migration - [x] Dynamic config now flows through backend control-plane APIs with Cosmos-backed storage
- [x] Backend snapshots now use a Cosmos-first repository with one-time legacy seeding during migration - [x] Backend snapshots now use a Cosmos-backed repository
- [x] Distributed entry and reconciliation locks now use a Cosmos-first repository with legacy fallback - [x] Distributed entry and reconciliation locks now use a Cosmos-backed repository
- [x] Capital ledger persistence now uses a Cosmos-first repository with one-time legacy seeding during migration - [x] Capital ledger persistence now uses a Cosmos-backed repository
- [x] Mobile platform auth requests now use the common React Native platform SDK - [x] Mobile platform auth requests now use the common React Native platform SDK
- [x] Backend risk and PnL aggregate reads now flow through repository abstractions instead of direct legacy service calls - [x] Backend risk and PnL aggregate reads now flow through repository abstractions instead of direct legacy service calls
- [x] Web history, profile, marketplace, config, and manual-entry flows now run through backend APIs instead of browser-side table access - [x] Web history, profile, marketplace, config, and manual-entry flows now run through backend APIs instead of browser-side table access
- [x] Release smoke coverage now exists for web auth and product accessibility flows, with a tracked mobile release smoke checklist in operations - [x] Release smoke coverage now exists for web auth and product accessibility flows, with a tracked mobile release smoke checklist in operations
- [x] Request ID propagation is now standardized across the main web/mobile API paths and echoed by backend HTTP responses - [x] Request ID propagation is now standardized across the main web/mobile API paths and echoed by backend HTTP responses
- [x] Backtest feature access now reads from an explicit backend feature-flags contract instead of scraping generic runtime config - [x] Backtest feature access now reads from an explicit backend feature-flags contract instead of scraping generic runtime config
- [x] Trading user profiles and marketplace presets now have Cosmos-backed authority paths with legacy seeding/mirroring during migration - [x] Trading user profiles and marketplace presets now have Cosmos-backed authority paths
- [x] Runtime order, trade-history, manual-entry, order-activity, and reconciliation-audit repositories now use Cosmos-backed trading-record storage instead of the legacy service layer
- [x] Runtime sub-tag repair now operates through the Cosmos-backed order repository
- [x] Root verification and lint flows now run successfully without sandbox-hostile script harness behavior - [x] Root verification and lint flows now run successfully without sandbox-hostile script harness behavior
- [-] DRY cleanup completed for runtime/config/bootstrap concerns, shared websocket auth helpers, platform-session handling, and request tracing, but not yet for all persistence and feature-flag concerns - [-] DRY cleanup completed for runtime/config/bootstrap concerns, shared websocket auth helpers, platform-session handling, and request tracing, but not yet for all persistence and feature-flag concerns
- [!] Full common-platform data-plane replacement remains a follow-up where selected trading-record repositories still depend on legacy Supabase storage because Cosmos-native equivalents are not finished yet - [-] Cosmos-only execution persistence is now in place for the main backend runtime paths; remaining cleanup is removing dormant legacy code paths and reference scripts
## 3. Guiding Rules ## 3. Guiding Rules
@ -52,7 +55,8 @@ It assumes:
2. Do not duplicate auth, kill switch, telemetry, or config bootstrap. 2. Do not duplicate auth, kill switch, telemetry, or config bootstrap.
3. Do not move core trading moat into common-platform packages. 3. Do not move core trading moat into common-platform packages.
4. Do not preserve legacy repo boundaries inside the new monorepo if they block clarity. 4. Do not preserve legacy repo boundaries inside the new monorepo if they block clarity.
5. Migrate by contract, not by uncontrolled file copying. 5. Rebuild by contract, not by uncontrolled file copying.
6. Do not preserve migration-only fallback behavior in the new runtime.
## 4. Critical Path ## 4. Critical Path
@ -182,8 +186,8 @@ Ensure all surfaces adopt one consistent platform model for auth, kill switch, t
- [x] Define correlation ID and request propagation strategy - [x] Define correlation ID and request propagation strategy
- [x] Define feature flag ownership and evaluation model - [x] Define feature flag ownership and evaluation model
- [x] Define system-of-record ownership by concern - [x] Define system-of-record ownership by concern
- [x] Define degraded-platform fallback behavior - [x] Define degraded-platform behavior
- [x] Define transitional adapters needed for legacy auth flows - [x] Define compatibility boundaries only where required to preserve domain behavior during derivation
### Deliverables ### Deliverables
@ -225,11 +229,11 @@ Make backend the stable authority before web and mobile migrate heavily onto it.
- [x] Integrate explicit kill-switch and maintenance semantics - [x] Integrate explicit kill-switch and maintenance semantics
- [x] Assign backend enforcement for global trade halt, tenant disable, and profile disable - [x] Assign backend enforcement for global trade halt, tenant disable, and profile disable
- [x] Add runtime control endpoints - [x] Add runtime control endpoints
- [x] Add platform-JWT verification with legacy fallback - [x] Add platform-JWT verification
- [x] Add Cosmos-backed global trading-control persistence - [x] Add Cosmos-backed global trading-control persistence
- [x] Move snapshots to Cosmos-first repository flow with legacy fallback - [x] Move snapshots to Cosmos-backed repository flow
- [x] Move distributed runtime locks to Cosmos-first repository flow with legacy fallback - [x] Move distributed runtime locks to Cosmos-backed repository flow
- [x] Move capital ledger persistence to Cosmos-first repository flow with legacy fallback - [x] Move capital ledger persistence to Cosmos-backed repository flow
- [-] Standardize admin controls and audit logging - [-] Standardize admin controls and audit logging
- [ ] Define admin audit event schema - [ ] Define admin audit event schema
- [ ] Define durable state ownership between memory, database, and exchange sync - [ ] Define durable state ownership between memory, database, and exchange sync
@ -368,7 +372,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
### Objective ### Objective
Remove duplicated implementation patterns exposed during migration. Remove duplicated implementation patterns exposed during derivation from the legacy repos.
### Checklist ### Checklist
@ -378,7 +382,7 @@ Remove duplicated implementation patterns exposed during migration.
- [x] Consolidate telemetry boot and event fields - [x] Consolidate telemetry boot and event fields
- [x] Consolidate kill-switch UX and service-state handling - [x] Consolidate kill-switch UX and service-state handling
- [x] Consolidate shared types for product contracts - [x] Consolidate shared types for product contracts
- [ ] Remove temporary migration-only adapters that are no longer needed - [-] Remove temporary derivation-only adapters that are no longer needed
### Guardrail ### Guardrail
@ -408,7 +412,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
- [ ] Add backend contract tests - [ ] Add backend contract tests
- [x] Add web auth and kill-switch smoke tests - [x] Add web auth and kill-switch smoke tests
- [x] Add mobile launch/auth/kill-switch smoke coverage - [x] Add mobile launch/auth/kill-switch smoke coverage
- [x] Add docs for local dev, CI, Docker, and fallback behaviors - [x] Add docs for local dev, CI, Docker, and degraded-platform behaviors
- [x] Define cutover sequencing from legacy repos - [x] Define cutover sequencing from legacy repos
- [x] Define rollback paths - [x] Define rollback paths
- [x] Define release go/no-go checklist - [x] Define release go/no-go checklist
@ -479,7 +483,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
- [ ] Mobile overview/alerts/positions/history - [ ] Mobile overview/alerts/positions/history
- [ ] DRY cleanup - [ ] DRY cleanup
- [x] Verification and cutover docs - [x] Verification and cutover docs
- [-] Backend Cosmos-first repository migration for safety-critical persistence - [x] Backend Cosmos-authoritative repository implementation for safety-critical persistence
### Recommended Rollout Order ### Recommended Rollout Order
@ -505,10 +509,15 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
### Risk: auth model becomes split between Supabase-specific flows and platform-service flows ### Risk: auth model becomes split between Supabase-specific flows and platform-service flows
- [ ] Mitigation: use an adapter strategy during migration - [x] Mitigation: preserve domain behavior while removing migration-only storage fallbacks
- [ ] Mitigation: define one authoritative session model early - [ ] Mitigation: define one authoritative session model early
- [ ] Mitigation: document transitional behavior explicitly - [ ] Mitigation: document transitional behavior explicitly
### Risk: repo-level verification stays red due to test-harness drift instead of product regressions
- [x] Mitigation: keep backend safety gates green while cutting over persistence
- [!] Mitigation: fix the current web Vitest `window.localStorage` harness issue before claiming a fully green root `pnpm verify`
### Risk: kill switch becomes semantically overloaded ### Risk: kill switch becomes semantically overloaded
- [ ] Mitigation: separate product maintenance mode from trade-halt control - [ ] Mitigation: separate product maintenance mode from trade-halt control