feat: move manual entries behind backend api
This commit is contained in:
parent
44f3171783
commit
ebaabaed47
@ -23,6 +23,11 @@ import {
|
|||||||
saveCurrentUserProfile,
|
saveCurrentUserProfile,
|
||||||
saveTradeProfileForUser,
|
saveTradeProfileForUser,
|
||||||
} from './profileRepository.js';
|
} from './profileRepository.js';
|
||||||
|
import {
|
||||||
|
deleteManualEntryForUser,
|
||||||
|
listManualEntriesForUser,
|
||||||
|
saveManualEntryForUser
|
||||||
|
} from './manualEntryRepository.js';
|
||||||
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
||||||
import { OperationalEvent } from '../domain/operationalEvents.js';
|
import { OperationalEvent } from '../domain/operationalEvents.js';
|
||||||
import { runBacktest } from '../backtest/index.js';
|
import { runBacktest } from '../backtest/index.js';
|
||||||
@ -1799,6 +1804,69 @@ export class ApiServer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.app.get('/api/manual-entries', this.requireAuth, async (req, res) => {
|
||||||
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
|
if (!authUserId) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await listManualEntriesForUser(authUserId, supabaseService);
|
||||||
|
res.json({ entries });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: `Failed to load manual entries: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.post('/api/manual-entries', this.requireAuth, async (req, res) => {
|
||||||
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
|
if (!authUserId) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = await saveManualEntryForUser(authUserId, req.body || {}, supabaseService);
|
||||||
|
res.status(201).json({ entry });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(400).json({ error: `Failed to save manual entry: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.put('/api/manual-entries/:id', this.requireAuth, async (req, res) => {
|
||||||
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
|
if (!authUserId) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.delete('/api/manual-entries/:id', this.requireAuth, async (req, res) => {
|
||||||
|
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||||
|
if (!authUserId) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteManualEntryForUser(authUserId, String(req.params.id || '').trim(), supabaseService);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(400).json({ error: `Failed to delete manual entry: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => {
|
this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const items = await listDynamicConfigEntries(supabaseService);
|
const items = await listDynamicConfigEntries(supabaseService);
|
||||||
|
|||||||
188
backend/src/services/manualEntryRepository.ts
Normal file
188
backend/src/services/manualEntryRepository.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
import type { supabaseService } from './SupabaseService.js';
|
||||||
|
|
||||||
|
type LegacySupabaseService = typeof supabaseService;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClient(legacyService?: LegacySupabaseService) {
|
||||||
|
const client = legacyService?.getClient?.();
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Manual entry store is not configured');
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 saveManualEntryForUser(
|
||||||
|
userId: string,
|
||||||
|
input: Partial<ManualEntryRecord>,
|
||||||
|
legacyService?: LegacySupabaseService
|
||||||
|
): Promise<ManualEntryRecord> {
|
||||||
|
const client = getClient(legacyService);
|
||||||
|
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 normalized = normalizeEntry(userId, input, existing);
|
||||||
|
if (!normalized.symbol) {
|
||||||
|
throw new Error('Symbol is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,23 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { EntryForm } from './EntryForm';
|
import { EntryForm } from './EntryForm';
|
||||||
import { tableNameStocks } from '../lib/const';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getSessionMock,
|
getSessionMock,
|
||||||
stocksInsertMock,
|
createManualEntryMock,
|
||||||
stocksUpdateEqMock,
|
updateManualEntryMock,
|
||||||
stocksUpdateMock,
|
|
||||||
historyInsertMock,
|
|
||||||
fromMock,
|
|
||||||
authMock
|
authMock
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
getSessionMock: vi.fn(),
|
getSessionMock: vi.fn(),
|
||||||
stocksInsertMock: vi.fn(),
|
createManualEntryMock: vi.fn(),
|
||||||
stocksUpdateEqMock: vi.fn(),
|
updateManualEntryMock: vi.fn(),
|
||||||
stocksUpdateMock: vi.fn(),
|
|
||||||
historyInsertMock: vi.fn(),
|
|
||||||
fromMock: vi.fn(),
|
|
||||||
authMock: { user: { id: 'user-1' } as any }
|
authMock: { user: { id: 'user-1' } as any }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -31,48 +24,29 @@ vi.mock('../lib/supabaseClient', () => ({
|
|||||||
supabase: {
|
supabase: {
|
||||||
auth: {
|
auth: {
|
||||||
getSession: getSessionMock
|
getSession: getSessionMock
|
||||||
},
|
}
|
||||||
from: fromMock
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/manualEntriesApi', () => ({
|
||||||
|
createManualEntry: createManualEntryMock,
|
||||||
|
updateManualEntry: updateManualEntryMock
|
||||||
|
}));
|
||||||
|
|
||||||
describe('EntryForm DOM flow', () => {
|
describe('EntryForm DOM flow', () => {
|
||||||
const alertMock = vi.fn();
|
const alertMock = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
stocksInsertMock.mockReset();
|
createManualEntryMock.mockReset();
|
||||||
stocksUpdateEqMock.mockReset();
|
updateManualEntryMock.mockReset();
|
||||||
stocksUpdateMock.mockReset();
|
|
||||||
historyInsertMock.mockReset();
|
|
||||||
getSessionMock.mockReset();
|
getSessionMock.mockReset();
|
||||||
fromMock.mockReset();
|
|
||||||
authMock.user = { id: 'user-1' };
|
authMock.user = { id: 'user-1' };
|
||||||
|
|
||||||
stocksInsertMock.mockResolvedValue({ error: null });
|
createManualEntryMock.mockResolvedValue({});
|
||||||
stocksUpdateEqMock.mockResolvedValue({ error: null });
|
updateManualEntryMock.mockResolvedValue({});
|
||||||
stocksUpdateMock.mockReturnValue({ eq: stocksUpdateEqMock });
|
|
||||||
historyInsertMock.mockResolvedValue({ error: null });
|
|
||||||
getSessionMock.mockResolvedValue({ data: { session: null } });
|
getSessionMock.mockResolvedValue({ data: { session: null } });
|
||||||
|
|
||||||
fromMock.mockImplementation((table: string) => {
|
|
||||||
if (table === tableNameStocks) {
|
|
||||||
return {
|
|
||||||
insert: stocksInsertMock,
|
|
||||||
update: stocksUpdateMock
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (table === 'trade_history') {
|
|
||||||
return {
|
|
||||||
insert: historyInsertMock
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
insert: vi.fn().mockResolvedValue({ error: null }),
|
|
||||||
update: vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) }))
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('fetch', vi.fn());
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||||
vi.stubGlobal('alert', alertMock);
|
vi.stubGlobal('alert', alertMock);
|
||||||
@ -91,7 +65,7 @@ describe('EntryForm DOM flow', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Add' }));
|
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(stocksInsertMock).toHaveBeenCalled();
|
expect(createManualEntryMock).toHaveBeenCalled();
|
||||||
expect(onSuccess).toHaveBeenCalled();
|
expect(onSuccess).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -127,13 +101,17 @@ describe('EntryForm DOM flow', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Add' }));
|
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(historyInsertMock).toHaveBeenCalledTimes(1);
|
expect(createManualEntryMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
expect(historyInsertMock.mock.calls[0][0][0].reason).toBe('Manual Log');
|
expect(createManualEntryMock.mock.calls[0][0]).toEqual(expect.objectContaining({
|
||||||
|
symbol: 'BTC/USD',
|
||||||
|
buy_price: 100,
|
||||||
|
sell_price: 110
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles database error by alerting user', async () => {
|
it('handles database error by alerting user', async () => {
|
||||||
stocksInsertMock.mockResolvedValue({ error: { message: 'DB Error' } });
|
createManualEntryMock.mockRejectedValueOnce(new Error('DB Error'));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<EntryForm onSuccess={vi.fn()} />);
|
render(<EntryForm onSuccess={vi.fn()} />);
|
||||||
@ -152,6 +130,6 @@ describe('EntryForm DOM flow', () => {
|
|||||||
await user.type(screen.getByPlaceholderText('BTC/USD'), 'BTC/USD');
|
await user.type(screen.getByPlaceholderText('BTC/USD'), 'BTC/USD');
|
||||||
await user.click(screen.getByRole('button', { name: 'Add' }));
|
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||||
|
|
||||||
expect(stocksInsertMock).not.toHaveBeenCalled();
|
expect(createManualEntryMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
|
|||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { supabase } from '../lib/supabaseClient';
|
import { supabase } from '../lib/supabaseClient';
|
||||||
import { useAuth } from '../components/AuthContext';
|
import { useAuth } from '../components/AuthContext';
|
||||||
import { tableNameStocks } from '../lib/const';
|
|
||||||
import { tradingRuntime } from '../lib/runtime';
|
import { tradingRuntime } from '../lib/runtime';
|
||||||
|
import { createManualEntry, updateManualEntry } from '../lib/manualEntriesApi';
|
||||||
|
|
||||||
interface EntryFormProps {
|
interface EntryFormProps {
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
@ -143,88 +143,13 @@ export function EntryForm({ onSuccess, initialData }: EntryFormProps) {
|
|||||||
throw new Error(`Execution Failed: ${resData.error}`);
|
throw new Error(`Execution Failed: ${resData.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If success, we continue to save to DB as usual for journaling
|
|
||||||
alert(`✅ Trade Executed! Order ID: ${resData.orderId}`);
|
alert(`✅ Trade Executed! Order ID: ${resData.orderId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initialData?.stock_instance_id) {
|
if (initialData?.stock_instance_id) {
|
||||||
// Update
|
await updateManualEntry(initialData.stock_instance_id, payload);
|
||||||
const { error } = await supabase
|
|
||||||
.from(tableNameStocks)
|
|
||||||
.update(payload)
|
|
||||||
.eq('stock_instance_id', initialData.stock_instance_id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// --- NEW: Log to Trade History if Closing ---
|
|
||||||
// If we are adding a sell_price to an open position, we consider it "Closing"
|
|
||||||
if (payload.sell_price && !initialData.sell_price && payload.buy_price) {
|
|
||||||
const entryPrice = payload.buy_price;
|
|
||||||
const exitPrice = payload.sell_price;
|
|
||||||
const size = payload.quantity || 0;
|
|
||||||
const pnl = (exitPrice - entryPrice) * size;
|
|
||||||
const pnlPercent = ((exitPrice - entryPrice) / entryPrice) * 100;
|
|
||||||
|
|
||||||
// Insert into trade_history
|
|
||||||
const { error: historyError } = await supabase
|
|
||||||
.from('trade_history')
|
|
||||||
.insert([{
|
|
||||||
user_id: user.id,
|
|
||||||
symbol: payload.symbol,
|
|
||||||
side: 'BUY',
|
|
||||||
entry_price: entryPrice,
|
|
||||||
exit_price: exitPrice,
|
|
||||||
size: size,
|
|
||||||
pnl: pnl,
|
|
||||||
pnl_percent: pnlPercent,
|
|
||||||
source: 'MANUAL',
|
|
||||||
reason: 'Manual Close',
|
|
||||||
timestamp: Date.now()
|
|
||||||
}]);
|
|
||||||
|
|
||||||
if (historyError) {
|
|
||||||
console.error("Failed to log manual trade to history:", historyError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Insert
|
await createManualEntry({ ...payload, stock_instance_id: crypto.randomUUID() });
|
||||||
// Remove ID to let DB generate it
|
|
||||||
const { error } = await supabase
|
|
||||||
.from(tableNameStocks)
|
|
||||||
.insert([{ ...payload, stock_instance_id: crypto.randomUUID() }]);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// --- NEW: Log to Trade History if Creating a Closed Entry (Journaling past trade) ---
|
|
||||||
if (payload.sell_price && payload.buy_price) {
|
|
||||||
const entryPrice = payload.buy_price;
|
|
||||||
const exitPrice = payload.sell_price;
|
|
||||||
const size = payload.quantity || 0;
|
|
||||||
const pnl = (exitPrice - entryPrice) * size;
|
|
||||||
const pnlPercent = ((exitPrice - entryPrice) / entryPrice) * 100;
|
|
||||||
|
|
||||||
// Insert into trade_history
|
|
||||||
const { error: historyError } = await supabase
|
|
||||||
.from('trade_history')
|
|
||||||
.insert([{
|
|
||||||
user_id: user.id,
|
|
||||||
symbol: payload.symbol,
|
|
||||||
side: 'BUY',
|
|
||||||
entry_price: entryPrice,
|
|
||||||
exit_price: exitPrice,
|
|
||||||
size: size,
|
|
||||||
pnl: pnl,
|
|
||||||
pnl_percent: pnlPercent,
|
|
||||||
source: 'MANUAL',
|
|
||||||
reason: 'Manual Log',
|
|
||||||
timestamp: Date.now()
|
|
||||||
}]);
|
|
||||||
|
|
||||||
if (historyError) {
|
|
||||||
console.error("Failed to log manual trade to history:", historyError.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
|
|||||||
77
web/src/lib/manualEntriesApi.ts
Normal file
77
web/src/lib/manualEntriesApi.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { supabase } from './supabaseClient';
|
||||||
|
import { tradingRuntime } from './runtime';
|
||||||
|
|
||||||
|
export interface ManualEntryPayload {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessToken(): Promise<string> {
|
||||||
|
const { data: sessionData } = await supabase.auth.getSession();
|
||||||
|
const accessToken = sessionData.session?.access_token;
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
const response = await fetch(`${tradingRuntime.tradingApiUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({} as any));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.error || `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchManualEntries(): Promise<ManualEntryPayload[]> {
|
||||||
|
const response = await apiRequest<{ entries: ManualEntryPayload[] }>('/api/manual-entries');
|
||||||
|
return Array.isArray(response.entries) ? response.entries : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createManualEntry(payload: ManualEntryPayload): Promise<ManualEntryPayload> {
|
||||||
|
const response = await apiRequest<{ entry: ManualEntryPayload }>('/api/manual-entries', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return response.entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateManualEntry(id: string, payload: ManualEntryPayload): Promise<ManualEntryPayload> {
|
||||||
|
const response = await apiRequest<{ entry: ManualEntryPayload }>(`/api/manual-entries/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return response.entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteManualEntry(id: string): Promise<void> {
|
||||||
|
await apiRequest<{ success: boolean }>(`/api/manual-entries/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,32 +1,35 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
|
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { EntriesTab } from './EntriesTab';
|
import { EntriesTab } from './EntriesTab';
|
||||||
import { tableNameStocks } from '../lib/const';
|
|
||||||
|
const {
|
||||||
const {
|
authState,
|
||||||
authState,
|
confirmMock,
|
||||||
fromMock,
|
fetchManualEntriesMock,
|
||||||
confirmMock
|
deleteManualEntryMock,
|
||||||
} = vi.hoisted(() => ({
|
createManualEntryMock
|
||||||
authState: {
|
} = vi.hoisted(() => ({
|
||||||
user: { id: 'user-1', email: 'test@demo.test' } as any,
|
authState: {
|
||||||
profile: { role: 'user' } as any
|
user: { id: 'user-1', email: 'test@demo.test' } as any,
|
||||||
},
|
profile: { role: 'user' } as any
|
||||||
fromMock: vi.fn(),
|
},
|
||||||
confirmMock: vi.fn()
|
confirmMock: vi.fn(),
|
||||||
}));
|
fetchManualEntriesMock: vi.fn(),
|
||||||
|
deleteManualEntryMock: vi.fn(),
|
||||||
|
createManualEntryMock: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../components/AuthContext', () => ({
|
vi.mock('../components/AuthContext', () => ({
|
||||||
useAuth: () => authState
|
useAuth: () => authState
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/supabaseClient', () => ({
|
vi.mock('../lib/manualEntriesApi', () => ({
|
||||||
supabase: {
|
fetchManualEntries: fetchManualEntriesMock,
|
||||||
from: fromMock
|
deleteManualEntry: deleteManualEntryMock,
|
||||||
}
|
createManualEntry: createManualEntryMock
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/EntryForm', () => ({
|
vi.mock('../components/EntryForm', () => ({
|
||||||
EntryForm: ({ onSuccess, initialData }: any) => (
|
EntryForm: ({ onSuccess, initialData }: any) => (
|
||||||
@ -37,21 +40,7 @@ vi.mock('../components/EntryForm', () => ({
|
|||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createMockQuery = (data: any, error: any = null) => {
|
describe('EntriesTab master suite', () => {
|
||||||
const chain: any = {
|
|
||||||
select: vi.fn(),
|
|
||||||
eq: vi.fn(),
|
|
||||||
order: vi.fn(),
|
|
||||||
then: vi.fn()
|
|
||||||
};
|
|
||||||
chain.select.mockReturnValue(chain);
|
|
||||||
chain.eq.mockReturnValue(chain);
|
|
||||||
chain.order.mockReturnValue(chain);
|
|
||||||
chain.then.mockImplementation((cb: any) => Promise.resolve({ data, error }).then(cb));
|
|
||||||
return chain;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('EntriesTab master suite', () => {
|
|
||||||
const mockEntry = {
|
const mockEntry = {
|
||||||
stock_instance_id: 'entry-1',
|
stock_instance_id: 'entry-1',
|
||||||
symbol: 'BTC/USDT',
|
symbol: 'BTC/USDT',
|
||||||
@ -75,26 +64,16 @@ describe('EntriesTab master suite', () => {
|
|||||||
settings: { executionMode: 'Paper' }
|
settings: { executionMode: 'Paper' }
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
authState.user = { id: 'user-1', email: 'test@demo.test' };
|
authState.user = { id: 'user-1', email: 'test@demo.test' };
|
||||||
authState.profile = { role: 'user' };
|
authState.profile = { role: 'user' };
|
||||||
vi.stubGlobal('confirm', confirmMock);
|
vi.stubGlobal('confirm', confirmMock);
|
||||||
vi.stubGlobal('crypto', { randomUUID: () => 'uuid-123' });
|
vi.stubGlobal('crypto', { randomUUID: () => 'uuid-123' });
|
||||||
|
fetchManualEntriesMock.mockResolvedValue([mockEntry]);
|
||||||
fromMock.mockImplementation((table: string) => {
|
deleteManualEntryMock.mockResolvedValue(undefined);
|
||||||
if (table === tableNameStocks) {
|
createManualEntryMock.mockResolvedValue({});
|
||||||
return {
|
});
|
||||||
select: () => createMockQuery([mockEntry]),
|
|
||||||
delete: () => ({ eq: () => Promise.resolve({ error: null }) }),
|
|
||||||
insert: () => Promise.resolve({ error: null })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
select: () => createMockQuery([])
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles entry drawer interactions', async () => {
|
it('handles entry drawer interactions', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@ -109,14 +88,14 @@ describe('EntriesTab master suite', () => {
|
|||||||
|
|
||||||
it('derives diverse entry states (LOCKED, BLOCKED, SUBMITTED, CONFIRMED, ORPHAN)', async () => {
|
it('derives diverse entry states (LOCKED, BLOCKED, SUBMITTED, CONFIRMED, ORPHAN)', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const entries = [
|
const entries = [
|
||||||
{ stock_instance_id: 's1', symbol: 'S1', active: true, status: 'active', is_real_trade: false },
|
{ stock_instance_id: 's1', symbol: 'S1', active: true, status: 'active', is_real_trade: false },
|
||||||
{ stock_instance_id: 's2', symbol: 'S2', active: false, status: 'cooldown', is_real_trade: false }, // Use 'cooldown' instead of 'blocked' because 'blocked' contains 'lock'
|
{ stock_instance_id: 's2', symbol: 'S2', active: false, status: 'cooldown', is_real_trade: false }, // Use 'cooldown' instead of 'blocked' because 'blocked' contains 'lock'
|
||||||
{ stock_instance_id: 's3', symbol: 'S3', active: false, status: 'submitted', is_real_trade: false },
|
{ stock_instance_id: 's3', symbol: 'S3', active: false, status: 'submitted', is_real_trade: false },
|
||||||
{ stock_instance_id: 's4', symbol: 'S4', active: false, status: 'confirmed', is_real_trade: false },
|
{ stock_instance_id: 's4', symbol: 'S4', active: false, status: 'confirmed', is_real_trade: false },
|
||||||
{ stock_instance_id: 's5', symbol: 'S5', active: false, status: 'orphan', is_real_trade: false },
|
{ stock_instance_id: 's5', symbol: 'S5', active: false, status: 'orphan', is_real_trade: false },
|
||||||
];
|
];
|
||||||
fromMock.mockImplementation(() => ({ select: () => createMockQuery(entries) }));
|
fetchManualEntriesMock.mockResolvedValue(entries);
|
||||||
|
|
||||||
render(<EntriesTab botState={mockBotState} />);
|
render(<EntriesTab botState={mockBotState} />);
|
||||||
|
|
||||||
@ -138,13 +117,13 @@ describe('EntriesTab master suite', () => {
|
|||||||
await waitFor(() => expect(screen.getByText('BTC/USDT')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('BTC/USDT')).toBeInTheDocument());
|
||||||
|
|
||||||
const card = screen.getByText('BTC/USDT').closest('.group');
|
const card = screen.getByText('BTC/USDT').closest('.group');
|
||||||
const buttons = within(card as HTMLElement).getAllByRole('button');
|
const buttons = within(card as HTMLElement).getAllByRole('button');
|
||||||
|
|
||||||
await fireEvent.click(buttons[1]);
|
await fireEvent.click(buttons[1]);
|
||||||
expect(fromMock).toHaveBeenCalledWith(tableNameStocks);
|
expect(createManualEntryMock).toHaveBeenCalled();
|
||||||
|
|
||||||
confirmMock.mockReturnValue(true);
|
confirmMock.mockReturnValue(true);
|
||||||
await fireEvent.click(buttons[2]);
|
await fireEvent.click(buttons[2]);
|
||||||
expect(fromMock).toHaveBeenCalledWith(tableNameStocks);
|
expect(deleteManualEntryMock).toHaveBeenCalledWith('entry-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { supabase } from '../lib/supabaseClient';
|
import { useAuth } from '../components/AuthContext';
|
||||||
import { useAuth } from '../components/AuthContext';
|
import { EntryForm } from '../components/EntryForm';
|
||||||
import { tableNameStocks } from '../lib/const';
|
import { Pencil, Trash2, Copy as CopyIcon, Search, Plus, Filter, LayoutGrid, Clock, ShieldCheck, Activity } from 'lucide-react';
|
||||||
import { EntryForm } from '../components/EntryForm';
|
import type { BotState } from '../hooks/useWebSocket';
|
||||||
import { Pencil, Trash2, Copy as CopyIcon, Search, Plus, Filter, LayoutGrid, Clock, ShieldCheck, Activity } from 'lucide-react';
|
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
|
||||||
import type { BotState } from '../hooks/useWebSocket';
|
import { createManualEntry, deleteManualEntry, fetchManualEntries, type ManualEntryPayload } from '../lib/manualEntriesApi';
|
||||||
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
|
|
||||||
|
|
||||||
interface Entry {
|
interface Entry {
|
||||||
stock_instance_id: string;
|
stock_instance_id: string;
|
||||||
@ -105,20 +104,14 @@ export const EntriesTab = ({ botState = DEFAULT_BOT_STATE }: EntriesTabProps) =>
|
|||||||
// Filter Logic
|
// Filter Logic
|
||||||
const filteredEntries = filterEntriesByTab(entries, activeTab);
|
const filteredEntries = filterEntriesByTab(entries, activeTab);
|
||||||
|
|
||||||
const fetchEntries = async () => {
|
const fetchEntries = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const data = await fetchManualEntries();
|
||||||
.from(tableNameStocks)
|
setEntries(data as Entry[]);
|
||||||
.select("*")
|
} catch (err: unknown) {
|
||||||
.eq("user_id", user.id)
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
.order("created_at", { ascending: false });
|
console.error("Error fetching entries:", message);
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
setEntries(data || []);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
console.error("Error fetching entries:", message);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -126,18 +119,16 @@ export const EntriesTab = ({ botState = DEFAULT_BOT_STATE }: EntriesTabProps) =>
|
|||||||
fetchEntries();
|
fetchEntries();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('⚠️ CRITICAL: Permanently delete this entry from neural watchlist?')) return;
|
if (!confirm('⚠️ CRITICAL: Permanently delete this entry from neural watchlist?')) return;
|
||||||
await supabase.from(tableNameStocks).delete().eq('stock_instance_id', id);
|
await deleteManualEntry(id);
|
||||||
fetchEntries();
|
fetchEntries();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClone = async (entry: Entry) => {
|
const handleClone = async (entry: Entry) => {
|
||||||
await supabase.from(tableNameStocks).insert([{
|
await createManualEntry(buildClonedEntryPayload(entry, crypto.randomUUID()) as unknown as ManualEntryPayload);
|
||||||
...buildClonedEntryPayload(entry, crypto.randomUUID())
|
fetchEntries();
|
||||||
}]);
|
};
|
||||||
fetchEntries();
|
|
||||||
};
|
|
||||||
|
|
||||||
const entryCards = filteredEntries.map((entry) => {
|
const entryCards = filteredEntries.map((entry) => {
|
||||||
const entryState = deriveEntryState(entry, botState);
|
const entryState = deriveEntryState(entry, botState);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user