learning_ai_invt_trdg/backend/src/services/manualEntryRepository.ts
2026-05-06 17:37:04 +00:00

243 lines
10 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;
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<ManualEntryRecord>, 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<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 listAllManualEntryDocuments(): Promise<ManualEntryDocument[]> {
const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type 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' },
]);
}
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 listManualEntries(options?: { userId?: string }): Promise<ManualEntryRecord[]> {
const rows = options?.userId
? await listManualEntryDocuments(options.userId)
: await listAllManualEntryDocuments();
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`);
}