learning_ai_invt_trdg/backend/src/services/manualEntryRepository.ts

171 lines
6.9 KiB
TypeScript

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;
buy_price?: number | null;
sell_price?: number | null;
buy_time?: string | null;
sell_time?: string | null;
quantity?: number | null;
filled_quantity?: number | null;
notes?: string | null;
status: string;
is_crypto: boolean;
is_real_trade: boolean;
label?: string | null;
entry_price?: number | null;
gain_threshold_for_sell?: number | null;
drop_threshold_for_buy?: number | 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 normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, existing?: ManualEntryRecord | null): ManualEntryRecord {
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,
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),
filled_quantity: normalizeNullableNumber(input.filled_quantity ?? existing?.filled_quantity),
notes: normalizeNullableString(input.notes ?? existing?.notes),
status: String(input.status || existing?.status || 'active'),
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),
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),
};
}
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;
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<ManualEntryRecord[]> {
const rows = await listManualEntryDocuments(userId);
return rows as ManualEntryRecord[];
}
export async function saveManualEntryForUser(userId: string, input: Partial<ManualEntryRecord>): Promise<ManualEntryRecord> {
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<ManualEntryDocument>(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<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`);
}