import { randomUUID } from 'node:crypto'; import { config } from '../config/index.js'; import logger from '../utils/logger.js'; 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; symbol: string; active: boolean; user_id: string; profile_id?: string | null; buy_price?: number | null; sell_price?: number | null; buy_time?: string | null; sell_time?: string | null; quantity?: number | null; amount_usd?: number | null; sizing_mode?: string | null; filled_quantity?: number | null; notes?: string | null; status: string; is_crypto: boolean; is_real_trade: boolean; label?: string | null; entry_price?: number | null; reference_price?: number | null; gain_threshold_for_sell?: number | null; drop_threshold_for_buy?: number | null; workflow_type?: string | null; simple_side?: string | null; drop_trigger_mode?: string | null; profit_target_mode?: string | null; linked_trade_id?: string | null; holding_mode?: string | null; automation_state?: string | null; } 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; if (value === null || value === '') return null; const num = Number(value); return Number.isFinite(num) ? num : null; } function normalizeNullableString(value: unknown): string | null | undefined { if (value === undefined) return undefined; if (value === null) return null; const text = String(value).trim(); return text ? text : null; } function deriveSimpleAutomationState(status: string, holdingMode: string | null | undefined): string | null { const normalizedStatus = String(status || '').trim().toLowerCase(); const normalizedMode = String(holdingMode || '').trim().toLowerCase(); if (normalizedMode === 'long_term') { return normalizedStatus === 'sellcompleted' ? 'closed' : 'paused_long_term'; } switch (normalizedStatus) { case 'simple_armed_buy': case 'simple_armed_sell': return 'armed'; case 'simple_entry_submitted': return 'entry_submitted'; case 'simple_bought': return 'holding_managed'; case 'simple_exit_submitted': return 'exit_submitted'; case 'sellcompleted': return 'closed'; default: return null; } } function normalizeEntry(userId: string, input: Partial, existing?: ManualEntryRecord | null): ManualEntryRecord { const workflowType = normalizeNullableString(input.workflow_type ?? existing?.workflow_type); const holdingMode = normalizeNullableString( input.holding_mode ?? existing?.holding_mode ?? (String(workflowType || '').trim().toLowerCase() === 'simple' ? 'short_term' : null) ); const status = String(input.status || existing?.status || 'active'); const automationState = normalizeNullableString( input.automation_state ?? existing?.automation_state ?? deriveSimpleAutomationState(status, holdingMode) ); return { stock_instance_id: String(input.stock_instance_id || existing?.stock_instance_id || randomUUID()), symbol: String(input.symbol || existing?.symbol || '').trim(), active: Boolean(input.active ?? existing?.active ?? true), user_id: userId, profile_id: normalizeNullableString(input.profile_id ?? existing?.profile_id), buy_price: normalizeNullableNumber(input.buy_price ?? existing?.buy_price), sell_price: normalizeNullableNumber(input.sell_price ?? existing?.sell_price), buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time), sell_time: normalizeNullableString(input.sell_time ?? existing?.sell_time), quantity: normalizeNullableNumber(input.quantity ?? existing?.quantity), amount_usd: normalizeNullableNumber(input.amount_usd ?? existing?.amount_usd), sizing_mode: normalizeNullableString(input.sizing_mode ?? existing?.sizing_mode), filled_quantity: normalizeNullableNumber(input.filled_quantity ?? existing?.filled_quantity), notes: normalizeNullableString(input.notes ?? existing?.notes), status, is_crypto: Boolean(input.is_crypto ?? existing?.is_crypto ?? false), is_real_trade: Boolean(input.is_real_trade ?? existing?.is_real_trade ?? false), label: normalizeNullableString(input.label ?? existing?.label), entry_price: normalizeNullableNumber(input.entry_price ?? existing?.entry_price), reference_price: normalizeNullableNumber(input.reference_price ?? existing?.reference_price), gain_threshold_for_sell: normalizeNullableNumber(input.gain_threshold_for_sell ?? existing?.gain_threshold_for_sell), drop_threshold_for_buy: normalizeNullableNumber(input.drop_threshold_for_buy ?? existing?.drop_threshold_for_buy), workflow_type: workflowType, simple_side: normalizeNullableString(input.simple_side ?? existing?.simple_side), drop_trigger_mode: normalizeNullableString(input.drop_trigger_mode ?? existing?.drop_trigger_mode), profit_target_mode: normalizeNullableString(input.profit_target_mode ?? existing?.profit_target_mode), linked_trade_id: normalizeNullableString(input.linked_trade_id ?? existing?.linked_trade_id), holding_mode: holdingMode, automation_state: automationState, }; } async function listManualEntryDocuments(userId: string): Promise { 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(MANUAL_ENTRY_CONTAINER, query, [ { name: '@productId', value: config.PRODUCT_ID }, { name: '@type', value: 'manual_entry' }, { name: '@userId', value: userId }, ]); } async function listAllManualEntryDocuments(): Promise { const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type ORDER BY c.created_at DESC'; return await queryDocuments(MANUAL_ENTRY_CONTAINER, query, [ { name: '@productId', value: config.PRODUCT_ID }, { name: '@type', value: 'manual_entry' }, ]); } async function findManualEntryDocument(userId: string, entryId: string): Promise { const rows = await queryDocuments(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 { 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; 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): Promise { const rows = await listManualEntryDocuments(userId); return rows as ManualEntryRecord[]; } export async function listManualEntries(options?: { userId?: string }): Promise { const rows = options?.userId ? await listManualEntryDocuments(options.userId) : await listAllManualEntryDocuments(); return rows as ManualEntryRecord[]; } export async function saveManualEntryForUser(userId: string, input: Partial): Promise { const entryId = String(input.stock_instance_id || '').trim(); 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(MANUAL_ENTRY_CONTAINER, payload as ManualEntryDocument); if (existing) { if (normalized.sell_price && !existing.sell_price && normalized.buy_price) { await insertHistoryRecord(userId, normalized, 'Manual Close'); } } else if (normalized.sell_price && normalized.buy_price) { await insertHistoryRecord(userId, normalized, 'Manual Log'); } return normalized; } export async function deleteManualEntryForUser(userId: string, entryId: string): Promise { 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(MANUAL_ENTRY_CONTAINER, tombstone); logger.info(`[ManualEntries] Marked ${toOptionalString(entryId) || 'unknown'} deleted in Cosmos`); }