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

View File

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

View File

@ -1,8 +1,16 @@
import { randomUUID } from 'node:crypto';
import { config } from '../config/index.js';
import logger from '../utils/logger.js';
import type { supabaseService } from './SupabaseService.js';
type LegacySupabaseService = typeof supabaseService;
import {
MANUAL_ENTRY_CONTAINER,
buildBaseDocument,
buildDocId,
clampNumber,
queryDocuments,
toOptionalString,
upsertDocument
} from './tradingRecordStore.js';
import { logTransaction } from './runtimeOrderRepository.js';
export interface ManualEntryRecord {
stock_instance_id: string;
@ -25,13 +33,13 @@ export interface ManualEntryRecord {
drop_threshold_for_buy?: number | null;
}
function getClient(legacyService?: LegacySupabaseService) {
const client = legacyService?.getClient?.();
if (!client) {
throw new Error('Manual entry store is not configured');
}
return client;
}
type ManualEntryDocument = ManualEntryRecord & {
id: string;
productId: string;
type: 'manual_entry';
created_at: string;
updated_at: string;
};
function normalizeNullableNumber(value: unknown): number | null | 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> {
const client = getClient(legacyService);
const entryPrice = Number(entry.buy_price || 0);
const exitPrice = Number(entry.sell_price || 0);
const size = Number(entry.quantity || 0);
async function listManualEntryDocuments(userId: string): Promise<ManualEntryDocument[]> {
const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type AND c.user_id = @userId ORDER BY c.created_at DESC';
return await queryDocuments<ManualEntryDocument>(MANUAL_ENTRY_CONTAINER, query, [
{ name: '@productId', value: config.PRODUCT_ID },
{ 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)) {
return;
}
const pnl = (exitPrice - entryPrice) * size;
const pnlPercent = entryPrice > 0 ? ((exitPrice - entryPrice) / entryPrice) * 100 : 0;
const { error } = await client
.from('trade_history')
.insert([{
user_id: userId,
symbol: entry.symbol,
side: 'BUY',
entry_price: entryPrice,
exit_price: exitPrice,
size,
pnl,
pnl_percent: pnlPercent,
source: 'MANUAL',
reason,
timestamp: Date.now()
}]);
if (error) {
logger.warn(`[ManualEntries] Failed to insert history row: ${error.message}`);
}
await logTransaction({
user_id: userId,
symbol: entry.symbol,
side: 'BUY',
entry_price: entryPrice,
exit_price: exitPrice,
size,
pnl,
pnl_percent: pnlPercent,
reason,
timestamp: Date.now(),
source: 'MANUAL'
});
}
export async function listManualEntriesForUser(userId: string, legacyService?: LegacySupabaseService): Promise<ManualEntryRecord[]> {
const client = getClient(legacyService);
const { data, error } = await client
.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 listManualEntriesForUser(userId: string): Promise<ManualEntryRecord[]> {
const rows = await listManualEntryDocuments(userId);
return rows as ManualEntryRecord[];
}
export async function saveManualEntryForUser(
userId: string,
input: Partial<ManualEntryRecord>,
legacyService?: LegacySupabaseService
): Promise<ManualEntryRecord> {
const client = getClient(legacyService);
export async function saveManualEntryForUser(userId: string, input: Partial<ManualEntryRecord>): Promise<ManualEntryRecord> {
const entryId = String(input.stock_instance_id || '').trim();
let existing: ManualEntryRecord | null = 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 existing = entryId ? await findManualEntryDocument(userId, entryId) : null;
const normalized = normalizeEntry(userId, input, existing);
if (!normalized.symbol) {
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) {
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) {
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;
}
export async function deleteManualEntryForUser(userId: string, entryId: string, legacyService?: LegacySupabaseService): Promise<void> {
const client = getClient(legacyService);
const { error } = await client
.from('stocks')
.delete()
.eq('stock_instance_id', entryId)
.eq('user_id', userId);
if (error) {
throw new Error(error.message);
export async function deleteManualEntryForUser(userId: string, entryId: string): Promise<void> {
const existing = await findManualEntryDocument(userId, entryId);
if (!existing) {
return;
}
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 type { supabaseService } from './SupabaseService.js';
import { ORDER_CONTAINER, queryDocuments } from './tradingRecordStore.js';
type LegacySupabaseService = typeof supabaseService;
function getClient(legacyService?: LegacySupabaseService) {
return legacyService?.getClient?.() ?? null;
}
type OrderActivityDocument = {
id?: string;
order_id?: string | 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: {
userId?: string;
limit?: number;
legacyService?: LegacySupabaseService;
}): Promise<any[]> {
const client = getClient(options.legacyService);
if (!client) return [];
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';
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';
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);
try {
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_order' },
];
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;
};
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 || [];
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);
} catch (error: any) {
logger.error(`[OrderActivityRepo] Recent order lookup unexpected error: ${error.message}`);
logger.error(`[OrderActivityRepo] Recent order lookup failed: ${error.message}`);
return [];
}
}

View File

@ -2,9 +2,9 @@ import { config } from '../config/index.js';
import logger from '../utils/logger.js';
import { observabilityService } from './observabilityService.js';
import {
type ReconciliationSubTagRepairSummary,
supabaseService
type ReconciliationSubTagRepairSummary
} from './SupabaseService.js';
import { repairMissingSubTagsForProfile } from './runtimeOrderRepository.js';
export interface ReconciliationSubTagRepairContext {
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 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,
lookbackHours,
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 type { supabaseService } from './SupabaseService.js';
type LegacySupabaseService = typeof supabaseService;
import { TRADE_HISTORY_CONTAINER, clampNumber, queryDocuments } from './tradingRecordStore.js';
import { config } from '../config/index.js';
export interface TradeHistoryKeyRecord {
trade_id: string;
@ -25,79 +24,78 @@ export interface TradeHistoryRow {
source?: string;
}
type TradeHistoryDocument = TradeHistoryRow & {
productId: string;
type: 'trade_history';
user_id?: string;
};
const startOfCurrentDayUtc = (): string => {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString();
};
function getClient(legacyService?: LegacySupabaseService) {
return legacyService?.getClient?.() ?? null;
async function listTradeHistoryDocuments(options: {
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> {
const client = getClient(legacyService);
if (!client || !profileId) return 0;
export async function getProfileDailyNetPnlUsd(profileId: string): Promise<number> {
if (!profileId) return 0;
try {
const { data, error } = await client
.from('trade_history')
.select('pnl, created_at')
.eq('profile_id', profileId)
.gte('created_at', startOfCurrentDayUtc())
.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;
const rows = await listTradeHistoryDocuments({ profileId, limit: 5000 });
const todayIso = startOfCurrentDayUtc();
return rows.reduce((sum, row) => {
if (String(row.created_at || '') < todayIso) return sum;
return sum + clampNumber(row.pnl);
}, 0);
} 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;
}
}
export async function getProfileDailyLossUsd(profileId: string, legacyService?: LegacySupabaseService): Promise<number> {
const netPnl = await getProfileDailyNetPnlUsd(profileId, legacyService);
export async function getProfileDailyLossUsd(profileId: string): Promise<number> {
const netPnl = await getProfileDailyNetPnlUsd(profileId);
return netPnl < 0 ? Math.abs(netPnl) : 0;
}
export async function getProfileConsecutiveLosses(
profileId: string,
lookback: number = 100,
legacyService?: LegacySupabaseService
): Promise<number> {
const client = getClient(legacyService);
if (!client || !profileId) return 0;
export async function getProfileConsecutiveLosses(profileId: string, lookback: number = 100): Promise<number> {
if (!profileId) return 0;
try {
const cappedLookback = Math.max(1, Math.min(500, Math.floor(lookback)));
const { data, error } = await client
.from('trade_history')
.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;
}
const rows = await listTradeHistoryDocuments({
profileId,
limit: Math.max(1, Math.min(500, Math.floor(lookback)))
});
let consecutiveLosses = 0;
for (const row of data || []) {
const pnl = Number((row as any)?.pnl || 0);
if (!Number.isFinite(pnl) || pnl >= 0) break;
for (const row of rows) {
const pnl = clampNumber(row.pnl);
if (pnl >= 0) break;
consecutiveLosses += 1;
}
return consecutiveLosses;
} 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;
}
}
@ -105,34 +103,17 @@ export async function getProfileConsecutiveLosses(
export async function listRecentTradeHistoryKeys(options: {
userId?: string;
limit?: number;
legacyService?: LegacySupabaseService;
}): Promise<TradeHistoryKeyRecord[]> {
const client = getClient(options.legacyService);
if (!client) return [];
try {
let query = client
.from('trade_history')
.select('trade_id,profile_id')
.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 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));
const rows = await listTradeHistoryDocuments(options);
return rows
.map((row) => ({
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) {
logger.error(`[TradeHistoryRepo] Recent trade key unexpected error: ${error.message}`);
logger.error(`[TradeHistoryRepo] Recent trade key lookup failed: ${error.message}`);
return [];
}
}
@ -140,31 +121,12 @@ export async function listRecentTradeHistoryKeys(options: {
export async function listRecentTradeHistory(options: {
userId?: string;
limit?: number;
legacyService?: LegacySupabaseService;
}): Promise<TradeHistoryRow[]> {
const client = getClient(options.legacyService);
if (!client) return [];
try {
let query = client
.from('trade_history')
.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[];
const rows = await listTradeHistoryDocuments(options);
return rows as TradeHistoryRow[];
} catch (error: any) {
logger.error(`[TradeHistoryRepo] Recent trade history unexpected error: ${error.message}`);
logger.error(`[TradeHistoryRepo] Recent trade history lookup failed: ${error.message}`);
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
- verification and CI expectations
- staged cutover from legacy repos
- staged rollout of the new monorepo deployment
- rollback rules
- release go/no-go checks
- post-cutover monitoring
@ -25,7 +25,6 @@ It covers:
- access to:
- platform-service
- Azure Cosmos DB
- optional legacy Supabase project during migration
### Workspace bootstrap
@ -70,18 +69,11 @@ pnpm --filter @bytelyst/trading-mobile dev
- `COSMOS_KEY`
- `COSMOS_DATABASE`
### Transitional legacy migration support
- `SUPABASE_URL`
- `SUPABASE_KEY`
- `SUPABASE_JWT_ISSUER`
- `SUPABASE_JWT_AUDIENCE`
Rule:
- platform-service and Cosmos are the target system
- Supabase remains transitional only where trading persistence has not yet been migrated
- trading user profiles, dynamic config, trading controls, snapshots, capital ledgers, and strategy presets now have Cosmos-backed authority paths
- platform-service and Cosmos are the only supported production systems for this repo
- 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 already use Cosmos-backed authority paths
## Verification Standard
@ -128,10 +120,9 @@ pnpm lint
### Backend cutover
- deploy backend with platform JWT support and Cosmos-backed trading controls enabled
- allow legacy Supabase reads only for controlled migration seeding where a Cosmos-native repository is not complete yet
- deploy backend with platform JWT support and Cosmos-backed control-plane and execution persistence enabled
- 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
### Web cutover
@ -179,14 +170,14 @@ Release is `go` only if all of the following are true:
- `pnpm smoke:release` passes
- platform-service auth is reachable from web and mobile
- 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
- backend tenant isolation checks are green
- 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:
- Supabase fallback is still required for a critical public flow that has no monitored contingency
- auth source of truth is ambiguous in production
- admin/runtime-control actions are not fully audited
- rollback owner or rollback commands are unclear
@ -233,8 +224,9 @@ Manual mobile release smoke is still required before broad rollout:
## Known Remaining Gaps
- full trading data-plane migration away from legacy Supabase is not complete
- web still carries some legacy compatibility layers around auth/profile bootstrap
- 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 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
- 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_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
@ -30,21 +31,23 @@ It assumes:
- [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] 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] Dynamic config now flows through backend control-plane APIs with Cosmos-first storage and one-time legacy seeding during migration
- [x] Backend snapshots now use a Cosmos-first repository with one-time legacy seeding during migration
- [x] Distributed entry and reconciliation locks now use a Cosmos-first repository with legacy fallback
- [x] Capital ledger persistence now uses a Cosmos-first repository with one-time legacy seeding during migration
- [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-backed storage
- [x] Backend snapshots now use a Cosmos-backed repository
- [x] Distributed entry and reconciliation locks now use a Cosmos-backed repository
- [x] Capital ledger persistence now uses a Cosmos-backed repository
- [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] 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] 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] 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
- [-] 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
@ -52,7 +55,8 @@ It assumes:
2. Do not duplicate auth, kill switch, telemetry, or config bootstrap.
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.
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
@ -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 feature flag ownership and evaluation model
- [x] Define system-of-record ownership by concern
- [x] Define degraded-platform fallback behavior
- [x] Define transitional adapters needed for legacy auth flows
- [x] Define degraded-platform behavior
- [x] Define compatibility boundaries only where required to preserve domain behavior during derivation
### 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] Assign backend enforcement for global trade halt, tenant disable, and profile disable
- [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] Move snapshots to Cosmos-first repository flow with legacy fallback
- [x] Move distributed runtime locks to Cosmos-first repository flow with legacy fallback
- [x] Move capital ledger persistence to Cosmos-first repository flow with legacy fallback
- [x] Move snapshots to Cosmos-backed repository flow
- [x] Move distributed runtime locks to Cosmos-backed repository flow
- [x] Move capital ledger persistence to Cosmos-backed repository flow
- [-] Standardize admin controls and audit logging
- [ ] Define admin audit event schema
- [ ] 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
Remove duplicated implementation patterns exposed during migration.
Remove duplicated implementation patterns exposed during derivation from the legacy repos.
### Checklist
@ -378,7 +382,7 @@ Remove duplicated implementation patterns exposed during migration.
- [x] Consolidate telemetry boot and event fields
- [x] Consolidate kill-switch UX and service-state handling
- [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
@ -408,7 +412,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
- [ ] Add backend contract tests
- [x] Add web auth and kill-switch smoke tests
- [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 rollback paths
- [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
- [ ] DRY cleanup
- [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
@ -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
- [ ] 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: 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
- [ ] Mitigation: separate product maintenance mode from trade-halt control