243 lines
10 KiB
TypeScript
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`);
|
|
}
|