refactor: move runtime trading records to cosmos
This commit is contained in:
parent
e043f3c79d
commit
5d3be081ee
@ -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,
|
||||
|
||||
@ -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}` });
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
85
backend/src/services/tradingRecordStore.ts
Normal file
85
backend/src/services/tradingRecordStore.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user