refactor: move web trading data behind backend apis
This commit is contained in:
parent
560c95a599
commit
b433686776
@ -7,6 +7,11 @@ import { SymbolMapper } from '../utils/symbolMapper.js';
|
||||
import { IExchangeConnector } from '../connectors/types.js';
|
||||
import { supabaseService } from './SupabaseService.js';
|
||||
import { healthTracker } from './healthTracker.js';
|
||||
import {
|
||||
getProfileConsecutiveLosses,
|
||||
getProfileDailyLossUsd,
|
||||
getProfileDailyNetPnlUsd,
|
||||
} from './tradeHistoryRepository.js';
|
||||
|
||||
export interface PortfolioGuardInput {
|
||||
symbol: string;
|
||||
@ -329,7 +334,7 @@ export class AutoTrader {
|
||||
|
||||
const maxDailyLossUsd = Number(riskLimits.maxDailyLossUsd);
|
||||
if (Number.isFinite(maxDailyLossUsd) && maxDailyLossUsd > 0) {
|
||||
const dailyLossUsd = await supabaseService.getProfileDailyLossUsd(profileId);
|
||||
const dailyLossUsd = await getProfileDailyLossUsd(profileId, supabaseService);
|
||||
if (dailyLossUsd >= maxDailyLossUsd) {
|
||||
return {
|
||||
allowed: false,
|
||||
@ -340,7 +345,7 @@ export class AutoTrader {
|
||||
|
||||
const dailyProfitTargetUsd = Number(riskLimits.dailyProfitTargetUsd);
|
||||
if (Number.isFinite(dailyProfitTargetUsd) && dailyProfitTargetUsd > 0) {
|
||||
const dailyNetPnl = await supabaseService.getProfileDailyNetPnlUsd(profileId);
|
||||
const dailyNetPnl = await getProfileDailyNetPnlUsd(profileId, supabaseService);
|
||||
if (dailyNetPnl >= dailyProfitTargetUsd) {
|
||||
return {
|
||||
allowed: false,
|
||||
@ -351,7 +356,7 @@ export class AutoTrader {
|
||||
|
||||
const maxConsecutiveLosses = Number(riskLimits.maxConsecutiveLosses);
|
||||
if (Number.isFinite(maxConsecutiveLosses) && maxConsecutiveLosses > 0) {
|
||||
const consecutiveLosses = await supabaseService.getProfileConsecutiveLosses(profileId);
|
||||
const consecutiveLosses = await getProfileConsecutiveLosses(profileId, 100, supabaseService);
|
||||
if (consecutiveLosses >= maxConsecutiveLosses) {
|
||||
return {
|
||||
allowed: false,
|
||||
|
||||
@ -35,6 +35,9 @@ import {
|
||||
listManualEntriesForUser,
|
||||
saveManualEntryForUser
|
||||
} from './manualEntryRepository.js';
|
||||
import { listRecentOrders } from './orderActivityRepository.js';
|
||||
import { createStrategyPreset, listStrategyPresets } from './strategyPresetRepository.js';
|
||||
import { listRecentTradeHistory, listRecentTradeHistoryKeys } from './tradeHistoryRepository.js';
|
||||
import * as runtimeOrderRepository from './runtimeOrderRepository.js';
|
||||
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
||||
import { OperationalEvent } from '../domain/operationalEvents.js';
|
||||
@ -1829,6 +1832,74 @@ export class ApiServer {
|
||||
}
|
||||
});
|
||||
|
||||
this.app.get('/api/positions/bootstrap', this.requireAuth, async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const authUserId = authReq.authUserId;
|
||||
if (!authUserId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isAdmin = await isTradingAdmin(authUserId, authReq.authRole);
|
||||
const scope = String(req.query.scope || 'user').toLowerCase();
|
||||
const wantsAll = scope === 'all' && isAdmin;
|
||||
const orderLimit = Math.max(1, Math.min(5000, parseInt(String(req.query.limit || '5000'), 10) || 5000));
|
||||
|
||||
const [entries, orders, historyTradeKeys, profiles] = await Promise.all([
|
||||
listManualEntriesForUser(authUserId, supabaseService),
|
||||
listRecentOrders({
|
||||
userId: wantsAll ? undefined : authUserId,
|
||||
limit: orderLimit,
|
||||
legacyService: supabaseService
|
||||
}),
|
||||
listRecentTradeHistoryKeys({
|
||||
userId: wantsAll ? undefined : authUserId,
|
||||
limit: orderLimit,
|
||||
legacyService: supabaseService
|
||||
}),
|
||||
wantsAll
|
||||
? listAllTradeProfiles(supabaseService)
|
||||
: listTradeProfilesForUser(authUserId, supabaseService)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
entries,
|
||||
orders,
|
||||
historyTradeKeys,
|
||||
profiles
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: `Failed to load positions bootstrap: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.get('/api/trade-history', this.requireAuth, async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const authUserId = authReq.authUserId;
|
||||
if (!authUserId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isAdmin = await isTradingAdmin(authUserId, authReq.authRole);
|
||||
const scope = String(req.query.scope || 'user').toLowerCase();
|
||||
const wantsAll = scope === 'all' && isAdmin;
|
||||
const limit = Math.max(1, Math.min(5000, parseInt(String(req.query.limit || '5000'), 10) || 5000));
|
||||
|
||||
const rows = await listRecentTradeHistory({
|
||||
userId: wantsAll ? undefined : authUserId,
|
||||
limit,
|
||||
legacyService: supabaseService
|
||||
});
|
||||
|
||||
res.json({ rows });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: `Failed to load trade history: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/manual-entries', this.requireAuth, async (req, res) => {
|
||||
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||
if (!authUserId) {
|
||||
@ -1898,6 +1969,34 @@ export class ApiServer {
|
||||
}
|
||||
});
|
||||
|
||||
this.app.get('/api/marketplace-presets', this.requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const presets = await listStrategyPresets(supabaseService);
|
||||
res.json({ presets });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: `Failed to load marketplace presets: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/marketplace-presets', this.requireAuth, this.requireAdmin, async (req, res) => {
|
||||
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||
if (!authUserId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...(req.body || {}),
|
||||
created_by: authUserId
|
||||
};
|
||||
await createStrategyPreset(payload, supabaseService);
|
||||
res.status(201).json({ success: true });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: `Failed to publish preset: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/backtest/run', this.requireAuth, async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const authUserId = authReq.authUserId;
|
||||
|
||||
56
backend/src/services/orderActivityRepository.ts
Normal file
56
backend/src/services/orderActivityRepository.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import logger from '../utils/logger.js';
|
||||
import type { supabaseService } from './SupabaseService.js';
|
||||
|
||||
type LegacySupabaseService = typeof supabaseService;
|
||||
|
||||
function getClient(legacyService?: LegacySupabaseService) {
|
||||
return legacyService?.getClient?.() ?? null;
|
||||
}
|
||||
|
||||
export async function listRecentOrders(options: {
|
||||
userId?: string;
|
||||
limit?: number;
|
||||
legacyService?: LegacySupabaseService;
|
||||
}): Promise<any[]> {
|
||||
const client = getClient(options.legacyService);
|
||||
if (!client) return [];
|
||||
|
||||
const orderColumnsV3 = 'id,order_id,user_id,profile_id,symbol,type,side,qty,quantity,price,status,timestamp,filled_at,created_at,trade_id,action,source,stop_loss,take_profit,sub_tag';
|
||||
const orderColumnsV2 = 'id,order_id,user_id,profile_id,symbol,type,side,qty,quantity,price,status,timestamp,filled_at,created_at,trade_id,action,source,stop_loss,take_profit';
|
||||
const orderColumnsLegacy = 'id,order_id,user_id,profile_id,symbol,type,side,qty,price,status,timestamp,created_at';
|
||||
const limit = Math.max(1, Math.min(5000, Math.floor(Number(options.limit || 5000))));
|
||||
|
||||
const runQuery = async (columns: string) => {
|
||||
let query = client
|
||||
.from('orders')
|
||||
.select(columns)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (options.userId) {
|
||||
query = query.eq('user_id', options.userId);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
try {
|
||||
const { data: v3Data, error: v3Error } = await runQuery(orderColumnsV3);
|
||||
if (!v3Error) return v3Data || [];
|
||||
|
||||
logger.warn(`[OrderActivityRepo] V3 order query failed, falling back to v2 columns: ${v3Error.message}`);
|
||||
const { data: v2Data, error: v2Error } = await runQuery(orderColumnsV2);
|
||||
if (!v2Error) return v2Data || [];
|
||||
|
||||
logger.warn(`[OrderActivityRepo] V2 order query failed, falling back to legacy columns: ${v2Error.message}`);
|
||||
const { data: legacyData, error: legacyError } = await runQuery(orderColumnsLegacy);
|
||||
if (legacyError) {
|
||||
logger.error(`[OrderActivityRepo] Legacy order query failed: ${legacyError.message}`);
|
||||
return [];
|
||||
}
|
||||
return legacyData || [];
|
||||
} catch (error: any) {
|
||||
logger.error(`[OrderActivityRepo] Recent order lookup unexpected error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
42
backend/src/services/strategyPresetRepository.ts
Normal file
42
backend/src/services/strategyPresetRepository.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import logger from '../utils/logger.js';
|
||||
import type { supabaseService } from './SupabaseService.js';
|
||||
|
||||
type LegacySupabaseService = typeof supabaseService;
|
||||
|
||||
function getClient(legacyService?: LegacySupabaseService) {
|
||||
return legacyService?.getClient?.() ?? null;
|
||||
}
|
||||
|
||||
export async function listStrategyPresets(legacyService?: LegacySupabaseService): Promise<any[]> {
|
||||
const client = getClient(legacyService);
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
const { data, error } = await client
|
||||
.from('strategy_presets')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
logger.error(`[StrategyPresetRepo] Preset lookup failed: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
} catch (error: any) {
|
||||
logger.error(`[StrategyPresetRepo] Preset lookup unexpected error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createStrategyPreset(payload: Record<string, unknown>, legacyService?: LegacySupabaseService): Promise<void> {
|
||||
const client = getClient(legacyService);
|
||||
if (!client) {
|
||||
throw new Error('Strategy preset store is unavailable');
|
||||
}
|
||||
|
||||
const { error } = await client.from('strategy_presets').insert([payload]);
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
170
backend/src/services/tradeHistoryRepository.ts
Normal file
170
backend/src/services/tradeHistoryRepository.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import logger from '../utils/logger.js';
|
||||
import type { supabaseService } from './SupabaseService.js';
|
||||
|
||||
type LegacySupabaseService = typeof supabaseService;
|
||||
|
||||
export interface TradeHistoryKeyRecord {
|
||||
trade_id: string;
|
||||
profile_id: string | null;
|
||||
}
|
||||
|
||||
export interface TradeHistoryRow {
|
||||
id?: string;
|
||||
timestamp?: number | string;
|
||||
symbol?: string;
|
||||
side?: string;
|
||||
size?: number;
|
||||
entry_price?: number;
|
||||
exit_price?: number;
|
||||
pnl?: number;
|
||||
pnl_percent?: number;
|
||||
reason?: string;
|
||||
profile_id?: string | null;
|
||||
created_at?: string;
|
||||
trade_id?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const startOfCurrentDayUtc = (): string => {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString();
|
||||
};
|
||||
|
||||
function getClient(legacyService?: LegacySupabaseService) {
|
||||
return legacyService?.getClient?.() ?? null;
|
||||
}
|
||||
|
||||
export async function getProfileDailyNetPnlUsd(profileId: string, legacyService?: LegacySupabaseService): Promise<number> {
|
||||
const client = getClient(legacyService);
|
||||
if (!client || !profileId) return 0;
|
||||
|
||||
try {
|
||||
const { data, error } = await client
|
||||
.from('trade_history')
|
||||
.select('pnl, created_at')
|
||||
.eq('profile_id', profileId)
|
||||
.gte('created_at', startOfCurrentDayUtc())
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5000);
|
||||
|
||||
if (error) {
|
||||
logger.error(`[TradeHistoryRepo] Daily net PnL lookup failed for ${profileId}: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (data || []).reduce((sum: number, row: any) => {
|
||||
const pnl = Number(row?.pnl || 0);
|
||||
return Number.isFinite(pnl) ? sum + pnl : sum;
|
||||
}, 0);
|
||||
} catch (error: any) {
|
||||
logger.error(`[TradeHistoryRepo] Daily net PnL unexpected error for ${profileId}: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProfileDailyLossUsd(profileId: string, legacyService?: LegacySupabaseService): Promise<number> {
|
||||
const netPnl = await getProfileDailyNetPnlUsd(profileId, legacyService);
|
||||
return netPnl < 0 ? Math.abs(netPnl) : 0;
|
||||
}
|
||||
|
||||
export async function getProfileConsecutiveLosses(
|
||||
profileId: string,
|
||||
lookback: number = 100,
|
||||
legacyService?: LegacySupabaseService
|
||||
): Promise<number> {
|
||||
const client = getClient(legacyService);
|
||||
if (!client || !profileId) return 0;
|
||||
|
||||
try {
|
||||
const cappedLookback = Math.max(1, Math.min(500, Math.floor(lookback)));
|
||||
const { data, error } = await client
|
||||
.from('trade_history')
|
||||
.select('pnl, created_at')
|
||||
.eq('profile_id', profileId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(cappedLookback);
|
||||
|
||||
if (error) {
|
||||
logger.error(`[TradeHistoryRepo] Consecutive loss lookup failed for ${profileId}: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let consecutiveLosses = 0;
|
||||
for (const row of data || []) {
|
||||
const pnl = Number((row as any)?.pnl || 0);
|
||||
if (!Number.isFinite(pnl) || pnl >= 0) break;
|
||||
consecutiveLosses += 1;
|
||||
}
|
||||
return consecutiveLosses;
|
||||
} catch (error: any) {
|
||||
logger.error(`[TradeHistoryRepo] Consecutive loss unexpected error for ${profileId}: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRecentTradeHistoryKeys(options: {
|
||||
userId?: string;
|
||||
limit?: number;
|
||||
legacyService?: LegacySupabaseService;
|
||||
}): Promise<TradeHistoryKeyRecord[]> {
|
||||
const client = getClient(options.legacyService);
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
let query = client
|
||||
.from('trade_history')
|
||||
.select('trade_id,profile_id')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(Math.max(1, Math.min(5000, Math.floor(Number(options.limit || 5000)))));
|
||||
|
||||
if (options.userId) {
|
||||
query = query.eq('user_id', options.userId);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) {
|
||||
logger.error(`[TradeHistoryRepo] Recent trade key lookup failed: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data || []).map((row: any) => ({
|
||||
trade_id: String(row?.trade_id || '').trim(),
|
||||
profile_id: row?.profile_id ? String(row.profile_id) : null
|
||||
})).filter((row) => Boolean(row.trade_id));
|
||||
} catch (error: any) {
|
||||
logger.error(`[TradeHistoryRepo] Recent trade key unexpected error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRecentTradeHistory(options: {
|
||||
userId?: string;
|
||||
limit?: number;
|
||||
legacyService?: LegacySupabaseService;
|
||||
}): Promise<TradeHistoryRow[]> {
|
||||
const client = getClient(options.legacyService);
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
let query = client
|
||||
.from('trade_history')
|
||||
.select('id,timestamp,symbol,side,size,entry_price,exit_price,pnl,pnl_percent,reason,profile_id,created_at,trade_id,source')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(Math.max(1, Math.min(5000, Math.floor(Number(options.limit || 5000)))));
|
||||
|
||||
if (options.userId) {
|
||||
query = query.eq('user_id', options.userId);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) {
|
||||
logger.error(`[TradeHistoryRepo] Recent trade history lookup failed: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data || []) as TradeHistoryRow[];
|
||||
} catch (error: any) {
|
||||
logger.error(`[TradeHistoryRepo] Recent trade history unexpected error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { supabase } from '../lib/supabaseClient';
|
||||
import { tableNameMarketplace } from '../lib/const';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchMarketplacePresets } from '../lib/marketplaceApi';
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
@ -244,30 +243,24 @@ export const PresetMarketplace: React.FC<PresetMarketplaceProps> = ({ onSelect,
|
||||
const [customPresets, setCustomPresets] = useState<StrategyPreset[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCustomPresets = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from(tableNameMarketplace)
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
const mappedData = data.map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
description: d.description,
|
||||
riskStyleId: d.risk_style_id,
|
||||
recommendedAssets: d.recommended_assets,
|
||||
typicalTradesPerDay: d.typical_trades_per_day,
|
||||
performanceTag: d.performance_tag,
|
||||
isPopular: d.is_popular,
|
||||
strategy_config: d.strategy_config
|
||||
}));
|
||||
setCustomPresets(mappedData as any);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching marketplace presets:', e);
|
||||
}
|
||||
const fetchCustomPresets = async () => {
|
||||
try {
|
||||
const data = await fetchMarketplacePresets();
|
||||
const mappedData = data.map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
description: d.description,
|
||||
riskStyleId: d.risk_style_id,
|
||||
recommendedAssets: d.recommended_assets,
|
||||
typicalTradesPerDay: d.typical_trades_per_day,
|
||||
performanceTag: d.performance_tag,
|
||||
isPopular: d.is_popular,
|
||||
strategy_config: d.strategy_config
|
||||
}));
|
||||
setCustomPresets(mappedData as any);
|
||||
} catch (e) {
|
||||
console.error('Error fetching marketplace presets:', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCustomPresets();
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { supabase } from '../lib/supabaseClient';
|
||||
import { tableNameTransactions } from '../lib/const';
|
||||
import { aggregateHistoryLedger, buildHistoryLedger } from '../lib/tradeHistoryLedger';
|
||||
import { publishMarketplacePreset } from '../lib/marketplaceApi';
|
||||
import { fetchTradeHistory } from '../lib/tradeHistoryApi';
|
||||
import {
|
||||
createTradeProfile,
|
||||
deleteTradeProfile,
|
||||
@ -324,18 +324,10 @@ export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfi
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let historyQuery = supabase
|
||||
.from(tableNameTransactions)
|
||||
.select('id,timestamp,symbol,side,size,entry_price,exit_price,pnl,pnl_percent,reason,profile_id,created_at,trade_id,source');
|
||||
|
||||
if (profile?.role !== 'admin' && authUser?.id) {
|
||||
historyQuery = historyQuery.eq('user_id', authUser.id);
|
||||
}
|
||||
|
||||
const [profilesData, meProfile, hRes] = await Promise.all([
|
||||
fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' }),
|
||||
fetchCurrentUserProfile().catch(() => null),
|
||||
historyQuery
|
||||
fetchTradeHistory({ scope: profile?.role === 'admin' ? 'all' : 'user' })
|
||||
]);
|
||||
const normalizedProfiles = (profilesData || []).map((profile: any) => ({
|
||||
...profile,
|
||||
@ -351,7 +343,7 @@ export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfi
|
||||
} : null));
|
||||
|
||||
const historyLedger = buildHistoryLedger({
|
||||
dbRows: hRes.data || [],
|
||||
dbRows: hRes || [],
|
||||
includeRealtime: false
|
||||
});
|
||||
const historyAggregate = aggregateHistoryLedger(historyLedger);
|
||||
@ -506,15 +498,14 @@ export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfi
|
||||
is_popular: true,
|
||||
created_by: authUser?.id,
|
||||
original_profile_id: p.id,
|
||||
strategy_config: p.strategy_config
|
||||
strategy_config: p.strategy_config || {}
|
||||
};
|
||||
|
||||
const { error } = await supabase.from('strategy_presets').insert([payload]);
|
||||
|
||||
if (error) {
|
||||
addToast(`Publish failed: ${error.message}`, 'error');
|
||||
} else {
|
||||
try {
|
||||
await publishMarketplacePreset(payload);
|
||||
addToast('Strategy published to Marketplace!', 'success');
|
||||
} catch (error: any) {
|
||||
addToast(`Publish failed: ${error?.message || 'Unknown error'}`, 'error');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
52
web/src/lib/authSession.ts
Normal file
52
web/src/lib/authSession.ts
Normal file
@ -0,0 +1,52 @@
|
||||
const AUTH_STORAGE_PREFIX = 'invttrdg_web';
|
||||
const ACCESS_TOKEN_KEY = `${AUTH_STORAGE_PREFIX}_access_token`;
|
||||
const REFRESH_TOKEN_KEY = `${AUTH_STORAGE_PREFIX}_refresh_token`;
|
||||
const USER_KEY = `${AUTH_STORAGE_PREFIX}_auth_user`;
|
||||
|
||||
export interface PlatformSessionUser {
|
||||
id: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
plan?: string;
|
||||
display_name?: string;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PlatformSession {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: PlatformSessionUser;
|
||||
}
|
||||
|
||||
function parseJson<T>(value: string | null): T | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredPlatformSession(): PlatformSession | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
const user = parseJson<PlatformSessionUser>(window.localStorage.getItem(USER_KEY));
|
||||
if (!accessToken || !refreshToken || !user?.id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlatformAccessToken(): string {
|
||||
const session = getStoredPlatformSession();
|
||||
const accessToken = session?.access_token;
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { supabase } from './supabaseClient';
|
||||
import { getPlatformAccessToken } from './authSession';
|
||||
import { tradingRuntime } from './runtime';
|
||||
|
||||
export interface ManualEntryPayload {
|
||||
@ -23,12 +23,7 @@ export interface ManualEntryPayload {
|
||||
}
|
||||
|
||||
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;
|
||||
return getPlatformAccessToken();
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
45
web/src/lib/marketplaceApi.ts
Normal file
45
web/src/lib/marketplaceApi.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { getPlatformAccessToken } from './authSession';
|
||||
import { tradingRuntime } from './runtime';
|
||||
|
||||
export interface StrategyPresetPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
risk_style_id: string;
|
||||
recommended_assets: string[];
|
||||
typical_trades_per_day: string;
|
||||
performance_tag: string;
|
||||
is_popular: boolean;
|
||||
created_by?: string;
|
||||
original_profile_id?: string;
|
||||
strategy_config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getPlatformAccessToken()}`,
|
||||
...(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 fetchMarketplacePresets(): Promise<any[]> {
|
||||
const response = await apiRequest<{ presets: any[] }>('/api/marketplace-presets');
|
||||
return Array.isArray(response.presets) ? response.presets : [];
|
||||
}
|
||||
|
||||
export async function publishMarketplacePreset(payload: StrategyPresetPayload): Promise<void> {
|
||||
await apiRequest<{ success: boolean }>('/api/marketplace-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
36
web/src/lib/positionsApi.ts
Normal file
36
web/src/lib/positionsApi.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { getPlatformAccessToken } from './authSession';
|
||||
import { tradingRuntime } from './runtime';
|
||||
|
||||
export interface PositionsBootstrapResponse {
|
||||
entries: any[];
|
||||
orders: any[];
|
||||
historyTradeKeys: Array<{ trade_id: string; profile_id: string | null }>;
|
||||
profiles: any[];
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getPlatformAccessToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
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 fetchPositionsBootstrap(options?: { scope?: 'user' | 'all'; limit?: number }): Promise<PositionsBootstrapResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.scope === 'all') {
|
||||
params.set('scope', 'all');
|
||||
}
|
||||
if (options?.limit) {
|
||||
params.set('limit', String(options.limit));
|
||||
}
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
return apiRequest<PositionsBootstrapResponse>(`/api/positions/bootstrap${query}`);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { supabase } from './supabaseClient';
|
||||
import { getPlatformAccessToken } from './authSession';
|
||||
import { tradingRuntime } from './runtime';
|
||||
|
||||
export interface TradeProfilePayload {
|
||||
@ -31,12 +31,7 @@ export interface CurrentUserProfile {
|
||||
}
|
||||
|
||||
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;
|
||||
return getPlatformAccessToken();
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
42
web/src/lib/tradeHistoryApi.ts
Normal file
42
web/src/lib/tradeHistoryApi.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { getPlatformAccessToken } from './authSession';
|
||||
import { tradingRuntime } from './runtime';
|
||||
|
||||
export interface TradeHistoryApiRow {
|
||||
id?: string;
|
||||
timestamp?: number | string;
|
||||
symbol?: string;
|
||||
side?: string;
|
||||
size?: number;
|
||||
entry_price?: number;
|
||||
exit_price?: number;
|
||||
pnl?: number;
|
||||
pnl_percent?: number;
|
||||
reason?: string;
|
||||
profile_id?: string | null;
|
||||
created_at?: string;
|
||||
trade_id?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export async function fetchTradeHistory(options?: { scope?: 'user' | 'all'; limit?: number }): Promise<TradeHistoryApiRow[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.scope === 'all') {
|
||||
params.set('scope', 'all');
|
||||
}
|
||||
if (options?.limit) {
|
||||
params.set('limit', String(options.limit));
|
||||
}
|
||||
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/trade-history${params.toString() ? `?${params.toString()}` : ''}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getPlatformAccessToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({} as any));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error || `Request failed (${response.status})`);
|
||||
}
|
||||
return Array.isArray(body?.rows) ? body.rows : [];
|
||||
}
|
||||
@ -1,12 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { BotState } from '../hooks/useWebSocket';
|
||||
import { supabase } from '../lib/supabaseClient';
|
||||
import { getPlatformAccessToken } from '../lib/authSession';
|
||||
import { tradingRuntime } from '../lib/runtime';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { tableNameStocks, tableNameOrders } from '../lib/const';
|
||||
import { Layers, ListFilter, Link2, GitBranch, AlertTriangle, Lock, RefreshCw, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
|
||||
import { fetchTradeProfiles } from '../lib/profileApi';
|
||||
import { fetchPositionsBootstrap } from '../lib/positionsApi';
|
||||
|
||||
interface PositionsTabProps {
|
||||
botState: BotState;
|
||||
@ -448,100 +447,29 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
|
||||
const fetchData = async () => {
|
||||
if (cancelled) return;
|
||||
const positionQuery = supabase
|
||||
.from(tableNameStocks)
|
||||
.select('stock_instance_id,symbol,quantity,buy_price,drop_threshold_for_buy,gain_threshold_for_sell,active,status')
|
||||
.eq('active', true)
|
||||
.eq('status', 'active');
|
||||
|
||||
// Manual entries remain user-scoped even for admins.
|
||||
const { data: posData, error: posError } = await positionQuery.eq('user_id', user.id);
|
||||
|
||||
const orderColumnsV3 = 'id,order_id,profile_id,symbol,type,side,qty,quantity,price,status,timestamp,filled_at,created_at,trade_id,action,source,stop_loss,take_profit,sub_tag';
|
||||
const orderColumnsV2 = 'id,order_id,profile_id,symbol,type,side,qty,quantity,price,status,timestamp,filled_at,created_at,trade_id,action,source,stop_loss,take_profit';
|
||||
const orderColumnsLegacy = 'id,order_id,profile_id,symbol,type,side,qty,price,status,timestamp,created_at';
|
||||
|
||||
let ordersQuery = supabase
|
||||
.from(tableNameOrders)
|
||||
.select(orderColumnsV3)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(ORDER_FETCH_LIMIT);
|
||||
|
||||
if (profile?.role !== 'admin') {
|
||||
ordersQuery = ordersQuery.eq('user_id', user.id);
|
||||
}
|
||||
|
||||
const { data: ordDataV3, error: ordErrorV3 } = await ordersQuery;
|
||||
let ordData = ordDataV3 as RawOrderRecord[] | null;
|
||||
|
||||
if (ordErrorV3) {
|
||||
console.warn('[PositionsTab] V3 order query failed, falling back to v2 columns:', ordErrorV3.message);
|
||||
let v2Query = supabase
|
||||
.from(tableNameOrders)
|
||||
.select(orderColumnsV2)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(ORDER_FETCH_LIMIT);
|
||||
|
||||
if (profile?.role !== 'admin') {
|
||||
v2Query = v2Query.eq('user_id', user.id);
|
||||
}
|
||||
|
||||
const { data: ordDataV2, error: ordErrorV2 } = await v2Query;
|
||||
ordData = ordDataV2 as RawOrderRecord[] | null;
|
||||
|
||||
if (ordErrorV2) {
|
||||
console.warn('[PositionsTab] V2 order query failed, falling back to legacy columns:', ordErrorV2.message);
|
||||
let legacyQuery = supabase
|
||||
.from(tableNameOrders)
|
||||
.select(orderColumnsLegacy)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(ORDER_FETCH_LIMIT);
|
||||
|
||||
if (profile?.role !== 'admin') {
|
||||
legacyQuery = legacyQuery.eq('user_id', user.id);
|
||||
}
|
||||
|
||||
const { data: ordDataLegacy, error: ordErrorLegacy } = await legacyQuery;
|
||||
if (ordErrorLegacy) {
|
||||
console.error('[PositionsTab] Legacy order query failed:', ordErrorLegacy.message);
|
||||
}
|
||||
ordData = ordDataLegacy as RawOrderRecord[] | null;
|
||||
}
|
||||
}
|
||||
|
||||
let historyQuery = supabase
|
||||
.from('trade_history')
|
||||
.select('trade_id,profile_id')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(ORDER_FETCH_LIMIT);
|
||||
|
||||
if (profile?.role !== 'admin') {
|
||||
historyQuery = historyQuery.eq('user_id', user.id);
|
||||
}
|
||||
|
||||
const { data: histData, error: histError } = await historyQuery;
|
||||
if (histError) {
|
||||
console.warn('[PositionsTab] History trade lookup failed:', histError.message);
|
||||
}
|
||||
|
||||
let posData: any[] = [];
|
||||
let ordData: RawOrderRecord[] | null = [];
|
||||
let histData: RawHistoryRecord[] | null = [];
|
||||
let profData: Array<{ id: string; name: string }> = [];
|
||||
let profError: Error | null = null;
|
||||
let bootstrapError: Error | null = null;
|
||||
try {
|
||||
const profiles = await fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' });
|
||||
profData = profiles.map((item: any) => ({
|
||||
const bootstrap = await fetchPositionsBootstrap({
|
||||
scope: profile?.role === 'admin' ? 'all' : 'user',
|
||||
limit: ORDER_FETCH_LIMIT
|
||||
});
|
||||
posData = Array.isArray(bootstrap.entries) ? bootstrap.entries : [];
|
||||
ordData = Array.isArray(bootstrap.orders) ? (bootstrap.orders as RawOrderRecord[]) : [];
|
||||
histData = Array.isArray(bootstrap.historyTradeKeys) ? (bootstrap.historyTradeKeys as RawHistoryRecord[]) : [];
|
||||
profData = (Array.isArray(bootstrap.profiles) ? bootstrap.profiles : []).map((item: any) => ({
|
||||
id: String(item.id),
|
||||
name: String(item.name || item.id || 'Unnamed Profile')
|
||||
}));
|
||||
} catch (error) {
|
||||
profError = error as Error;
|
||||
bootstrapError = error as Error;
|
||||
}
|
||||
if (bootstrapError) {
|
||||
console.error('[PositionsTab] Failed loading positions bootstrap:', bootstrapError.message);
|
||||
}
|
||||
|
||||
if (posError) {
|
||||
console.error('[PositionsTab] Failed loading manual positions:', posError.message);
|
||||
}
|
||||
if (profError) {
|
||||
console.error('[PositionsTab] Failed loading profiles:', profError.message);
|
||||
}
|
||||
|
||||
const tradeKeys = Array.from(new Set(
|
||||
((histData as RawHistoryRecord[] | null) || [])
|
||||
@ -1436,15 +1364,11 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
{pos.source === 'BOT' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Are you sure you want to CLOSE ${pos.symbol}?`)) return;
|
||||
try {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const accessToken = sessionData.session?.access_token;
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
if (!confirm(`Are you sure you want to CLOSE ${pos.symbol}?`)) return;
|
||||
try {
|
||||
const accessToken = getPlatformAccessToken();
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/close`, {
|
||||
method: 'POST',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user