feat(simple): add amount sizing and runtime status
This commit is contained in:
parent
dad47fc13d
commit
3d505db8d8
@ -137,6 +137,7 @@ export const config = {
|
||||
ENABLE_STRICT_CAPITAL_GUARD: process.env.ENABLE_STRICT_CAPITAL_GUARD !== 'false',
|
||||
STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: parseFloat(process.env.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || process.env.MAX_SLIPPAGE_PERCENT || '1.0'),
|
||||
STRICT_CAPITAL_FEE_BUFFER_PCT: parseFloat(process.env.STRICT_CAPITAL_FEE_BUFFER_PCT || '0.15'),
|
||||
STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT: parseFloat(process.env.STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT || '3'),
|
||||
STRICT_CAPITAL_MIN_RESERVE_USD: parseFloat(process.env.STRICT_CAPITAL_MIN_RESERVE_USD || '0'),
|
||||
ENTRY_AUTO_REDUCE_ALERT_MIN_PCT: parseFloat(process.env.ENTRY_AUTO_REDUCE_ALERT_MIN_PCT || '0.05'),
|
||||
ENTRY_AUTO_REDUCE_ALERT_MIN_USD: parseFloat(process.env.ENTRY_AUTO_REDUCE_ALERT_MIN_USD || '25'),
|
||||
@ -333,6 +334,7 @@ const dynamicConfigParsers: Record<string, (value: unknown) => unknown> = {
|
||||
ENABLE_STRICT_CAPITAL_GUARD: (value) => toBoolean(value, config.ENABLE_STRICT_CAPITAL_GUARD),
|
||||
STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: (value) => toNumber(value, config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT),
|
||||
STRICT_CAPITAL_FEE_BUFFER_PCT: (value) => toNumber(value, config.STRICT_CAPITAL_FEE_BUFFER_PCT),
|
||||
STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT: (value) => toNumber(value, config.STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT),
|
||||
STRICT_CAPITAL_MIN_RESERVE_USD: (value) => toNumber(value, config.STRICT_CAPITAL_MIN_RESERVE_USD),
|
||||
ENTRY_AUTO_REDUCE_ALERT_MIN_PCT: (value) => toNumber(value, config.ENTRY_AUTO_REDUCE_ALERT_MIN_PCT),
|
||||
ENTRY_AUTO_REDUCE_ALERT_MIN_USD: (value) => toNumber(value, config.ENTRY_AUTO_REDUCE_ALERT_MIN_USD),
|
||||
|
||||
@ -7,11 +7,11 @@ import { AccountSnapshot, IExchangeConnector, Candle, ExchangeCapabilities, Exch
|
||||
export class AlpacaConnector implements IExchangeConnector {
|
||||
private client: any;
|
||||
|
||||
constructor(apiKey?: string, apiSecret?: string) {
|
||||
constructor(apiKey?: string, apiSecret?: string, options?: { paper?: boolean }) {
|
||||
this.client = new (Alpaca as any)({
|
||||
keyId: apiKey || config.ALPACA_API_KEY,
|
||||
secretKey: apiSecret || config.ALPACA_API_SECRET,
|
||||
paper: config.PAPER_TRADING,
|
||||
paper: options?.paper ?? config.PAPER_TRADING,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -3,15 +3,20 @@ import { AlpacaConnector } from './alpaca.js';
|
||||
import { CCXTConnector } from './ccxt.js';
|
||||
import { IExchangeConnector } from './types.js';
|
||||
|
||||
export class ConnectorFactory {
|
||||
static getConnector(): IExchangeConnector {
|
||||
return this.getCustomConnector(config.PROVIDER);
|
||||
}
|
||||
|
||||
static getCustomConnector(provider: string, apiKey?: string, apiSecret?: string): IExchangeConnector {
|
||||
export class ConnectorFactory {
|
||||
static getConnector(): IExchangeConnector {
|
||||
return this.getCustomConnector(config.PROVIDER);
|
||||
}
|
||||
|
||||
static getCustomConnector(
|
||||
provider: string,
|
||||
apiKey?: string,
|
||||
apiSecret?: string,
|
||||
options?: { paper?: boolean }
|
||||
): IExchangeConnector {
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'alpaca':
|
||||
return new AlpacaConnector(apiKey, apiSecret);
|
||||
return new AlpacaConnector(apiKey, apiSecret, options);
|
||||
case 'ccxt':
|
||||
return new CCXTConnector(apiKey, apiSecret);
|
||||
default:
|
||||
|
||||
@ -36,6 +36,17 @@ const toNonNegativeNumber = (value: unknown): number | null => {
|
||||
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
|
||||
};
|
||||
|
||||
const resolveSimpleDesiredQty = (entry: ManualEntryRecord, referencePrice: number): number | null => {
|
||||
const sizingMode = String(entry.sizing_mode || 'quantity').trim().toLowerCase();
|
||||
if (sizingMode === 'amount') {
|
||||
const amountUsd = toPositiveNumber(entry.amount_usd);
|
||||
if (!(amountUsd && amountUsd > 0) || !(referencePrice > 0)) return null;
|
||||
const derivedQty = amountUsd / referencePrice;
|
||||
return derivedQty > 0 ? derivedQty : null;
|
||||
}
|
||||
return toPositiveNumber(entry.quantity);
|
||||
};
|
||||
|
||||
const normalizeTriggerMode = (value: unknown): 'dollar' | 'percent' | null => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'dollar' || normalized === 'percent') return normalized;
|
||||
@ -166,7 +177,16 @@ async function main() {
|
||||
logger.info(`🚨 Bot Initialized for ${config.SYMBOLS.length} Symbols: ${config.SYMBOLS.join(', ')}`);
|
||||
|
||||
// --- 0. Initialize Modular Exchanges ---
|
||||
const dataExchange = ConnectorFactory.getCustomConnector(config.DATA_PROVIDER, primaryAlpacaKey, primaryAlpacaSecret);
|
||||
const dataExchange = ConnectorFactory.getCustomConnector(
|
||||
config.DATA_PROVIDER,
|
||||
primaryAlpacaKey,
|
||||
primaryAlpacaSecret,
|
||||
{
|
||||
paper: !isPlaceholder(config.ALPACA_API_KEY)
|
||||
? config.PAPER_TRADING
|
||||
: preferredPrimaryUserCredentials.source === 'paper',
|
||||
}
|
||||
);
|
||||
const apiServer = new ApiServer(config.API_PORT);
|
||||
apiServer.pruneSymbols(config.SYMBOLS);
|
||||
const proEngine = new ProStrategyEngine(dataExchange);
|
||||
@ -263,9 +283,10 @@ async function main() {
|
||||
if (shouldArmSimpleBuy(entry)) {
|
||||
const currentPrice = resolveSimpleMarketPrice(entry) ?? toPositiveNumber(entry.reference_price);
|
||||
const triggerPrice = computeSimpleBuyTriggerPrice(entry);
|
||||
const desiredQty = toPositiveNumber(entry.quantity);
|
||||
if (!(currentPrice && currentPrice > 0) || !triggerPrice) continue;
|
||||
const desiredQty = resolveSimpleDesiredQty(entry, currentPrice);
|
||||
const threshold = toNonNegativeNumber(entry.drop_threshold_for_buy);
|
||||
if (!triggerPrice || !desiredQty || !(currentPrice && currentPrice > 0)) continue;
|
||||
if (desiredQty === null) continue;
|
||||
if (currentPrice > triggerPrice) continue;
|
||||
|
||||
const result = await ctx.manualTrader.executeRequest(
|
||||
@ -285,6 +306,7 @@ async function main() {
|
||||
await saveManualEntryForUser(entry.user_id, {
|
||||
...entry,
|
||||
profile_id: entry.profile_id || ctx.profileId,
|
||||
linked_trade_id: result.tradeId || entry.linked_trade_id,
|
||||
filled_quantity: result.adjustedQty ?? entry.filled_quantity,
|
||||
buy_time: new Date().toISOString(),
|
||||
status: 'simple_entry_submitted',
|
||||
@ -491,7 +513,12 @@ async function main() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userExecExchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, userKey, userSecret);
|
||||
const userExecExchange = ConnectorFactory.getCustomConnector(
|
||||
config.EXECUTION_PROVIDER,
|
||||
userKey,
|
||||
userSecret,
|
||||
{ paper: preferredCredentials.source === 'paper' }
|
||||
);
|
||||
const userExecutor = new TradeExecutor(userExecExchange, apiServer, user.user_id, profile.id);
|
||||
userExecutor.setProfileSettings(profile);
|
||||
const monitoredSymbols = resolveProfileSymbols(profile);
|
||||
@ -572,7 +599,12 @@ async function main() {
|
||||
if (!userKey || !userSecret) continue;
|
||||
|
||||
const defaultProfileId = `default-${user.user_id}`;
|
||||
const userExecExchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, userKey, userSecret);
|
||||
const userExecExchange = ConnectorFactory.getCustomConnector(
|
||||
config.EXECUTION_PROVIDER,
|
||||
userKey,
|
||||
userSecret,
|
||||
{ paper: preferredCredentials.source === 'paper' }
|
||||
);
|
||||
const userExecutor = new TradeExecutor(userExecExchange, apiServer, user.user_id, defaultProfileId);
|
||||
await userExecutor.syncPositions(config.SYMBOLS);
|
||||
await userExecutor.rebuildStartupState();
|
||||
|
||||
@ -65,7 +65,7 @@ export class ManualTrader {
|
||||
userId?: string,
|
||||
sl?: number,
|
||||
tp?: number
|
||||
): Promise<{ success: boolean; orderId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> {
|
||||
): Promise<{ success: boolean; orderId?: string; tradeId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> {
|
||||
const signalSide = (side.toLowerCase() === 'buy') ? SignalDirection.BUY : SignalDirection.SELL;
|
||||
const requestedQty = Number(qty);
|
||||
if (!Number.isFinite(requestedQty) || requestedQty <= 0) {
|
||||
@ -120,7 +120,7 @@ export class ManualTrader {
|
||||
signalSide,
|
||||
adjustedQty,
|
||||
type as 'market' | 'limit',
|
||||
price,
|
||||
type === 'market' ? estimatedPrice : price,
|
||||
sl,
|
||||
tp,
|
||||
userId
|
||||
|
||||
@ -410,11 +410,34 @@ export class TradeExecutor {
|
||||
return false;
|
||||
}
|
||||
|
||||
private async hasActiveTradeId(tradeId?: string): Promise<boolean> {
|
||||
const normalized = String(tradeId || '').trim();
|
||||
if (!normalized) return false;
|
||||
private async hasActiveTradeId(tradeId?: string): Promise<boolean> {
|
||||
const normalized = String(tradeId || '').trim();
|
||||
if (!normalized) return false;
|
||||
return await runtimeOrderRepository.hasActiveOrderForTradeId(normalized, this.profileId);
|
||||
}
|
||||
}
|
||||
|
||||
private async hasOpenLifecycleEvidence(profileId: string, symbol: string): Promise<boolean> {
|
||||
const normalizedSymbol = String(symbol || '').trim();
|
||||
if (!profileId || !normalizedSymbol) return false;
|
||||
|
||||
const openOrders = await runtimeOrderRepository.getOpenOrdersForProfile(profileId);
|
||||
const symbolCandidates = new Set([
|
||||
normalizedSymbol,
|
||||
normalizedSymbol.toUpperCase(),
|
||||
SymbolMapper.toTradeSymbol(normalizedSymbol, String(config.EXECUTION_PROVIDER || '').trim() || 'alpaca'),
|
||||
SymbolMapper.toTradeSymbol(normalizedSymbol.toUpperCase(), String(config.EXECUTION_PROVIDER || '').trim() || 'alpaca')
|
||||
].filter(Boolean).map((value) => String(value).trim().toUpperCase()));
|
||||
|
||||
const hasEntryOrder = openOrders.some((order) => {
|
||||
const orderSymbol = String(order?.symbol || '').trim().toUpperCase();
|
||||
if (!symbolCandidates.has(orderSymbol)) return false;
|
||||
return normalizeOrderAction(order?.action) === 'ENTRY';
|
||||
});
|
||||
if (hasEntryOrder) return true;
|
||||
|
||||
const virtualPosition = await runtimeOrderRepository.getVirtualOpenPosition(profileId, normalizedSymbol);
|
||||
return Boolean(virtualPosition && Number(virtualPosition.qty) > 0);
|
||||
}
|
||||
|
||||
private async isTradeAlreadyFinalized(tradeId?: string): Promise<boolean> {
|
||||
const normalized = String(tradeId || '').trim();
|
||||
@ -496,13 +519,19 @@ export class TradeExecutor {
|
||||
return config.ENABLE_STRICT_CAPITAL_GUARD !== false;
|
||||
}
|
||||
|
||||
private strictCapitalCostMultiplier(): number {
|
||||
if (!this.isStrictCapitalGuardEnabled()) return 1;
|
||||
const slippagePct = Math.max(0, Number(config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || 0));
|
||||
const feePct = Math.max(0, Number(config.STRICT_CAPITAL_FEE_BUFFER_PCT || 0));
|
||||
const multiplier = 1 + ((slippagePct + feePct) / 100);
|
||||
return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1;
|
||||
}
|
||||
private strictCapitalCostMultiplier(orderType?: 'market' | 'limit', symbol?: string): number {
|
||||
if (!this.isStrictCapitalGuardEnabled()) return 1;
|
||||
const slippagePct = Math.max(0, Number(config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || 0));
|
||||
const feePct = Math.max(0, Number(config.STRICT_CAPITAL_FEE_BUFFER_PCT || 0));
|
||||
const normalizedSymbol = String(symbol || '').trim().toUpperCase();
|
||||
const isCryptoMarket = (orderType || 'market') === 'market'
|
||||
&& (config.ASSET_CLASS === 'crypto' || normalizedSymbol.endsWith('USD') || normalizedSymbol.endsWith('USDT'));
|
||||
const marketCryptoPct = isCryptoMarket
|
||||
? Math.max(0, Number(config.STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT || 3))
|
||||
: 0;
|
||||
const multiplier = 1 + ((slippagePct + feePct + marketCryptoPct) / 100);
|
||||
return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1;
|
||||
}
|
||||
|
||||
private strictCapitalMinReserveUsd(): number {
|
||||
if (!this.isStrictCapitalGuardEnabled()) return 0;
|
||||
@ -510,12 +539,13 @@ export class TradeExecutor {
|
||||
return Number.isFinite(reserve) && reserve > 0 ? reserve : 0;
|
||||
}
|
||||
|
||||
private clampBuyQtyToAvailableCapital(
|
||||
symbol: string,
|
||||
requestedQty: number,
|
||||
requestedPrice: number | undefined,
|
||||
availableCapital: number
|
||||
): number {
|
||||
private clampBuyQtyToAvailableCapital(
|
||||
symbol: string,
|
||||
requestedQty: number,
|
||||
requestedPrice: number | undefined,
|
||||
availableCapital: number,
|
||||
orderType: 'market' | 'limit' = 'market'
|
||||
): number {
|
||||
if (!Number.isFinite(requestedQty) || requestedQty <= 0) return 0;
|
||||
if (!Number.isFinite(availableCapital) || availableCapital <= 0) return 0;
|
||||
|
||||
@ -527,7 +557,7 @@ export class TradeExecutor {
|
||||
const unitPrice = this.resolveOrderReferencePrice(symbol, requestedPrice);
|
||||
if (!(unitPrice > 0)) return 0;
|
||||
|
||||
const effectiveUnitCost = unitPrice * this.strictCapitalCostMultiplier();
|
||||
const effectiveUnitCost = unitPrice * this.strictCapitalCostMultiplier(orderType, symbol);
|
||||
if (!(effectiveUnitCost > 0)) return 0;
|
||||
const maxQty = this.roundDownQty(budget / effectiveUnitCost);
|
||||
if (!(maxQty > 0)) return 0;
|
||||
@ -606,10 +636,10 @@ export class TradeExecutor {
|
||||
return Number(Math.max(notional, minimum));
|
||||
}
|
||||
|
||||
private estimateOrderCost(symbol: string, qty: number, price?: number): number {
|
||||
if (!Number.isFinite(qty) || qty <= 0) return 0;
|
||||
const unitPrice = this.resolveOrderReferencePrice(symbol, price);
|
||||
const notional = qty * unitPrice * this.strictCapitalCostMultiplier();
|
||||
private estimateOrderCost(symbol: string, qty: number, price?: number, orderType: 'market' | 'limit' = 'market'): number {
|
||||
if (!Number.isFinite(qty) || qty <= 0) return 0;
|
||||
const unitPrice = this.resolveOrderReferencePrice(symbol, price);
|
||||
const notional = qty * unitPrice * this.strictCapitalCostMultiplier(orderType, symbol);
|
||||
const minimum = Number.isFinite(config.MIN_NOTIONAL_USD) && config.MIN_NOTIONAL_USD > 0 ? config.MIN_NOTIONAL_USD : 0;
|
||||
const base = Math.max(notional, minimum);
|
||||
return Number(base + this.strictCapitalMinReserveUsd());
|
||||
@ -911,7 +941,7 @@ export class TradeExecutor {
|
||||
sl?: number,
|
||||
tp?: number,
|
||||
userIdOverride?: string
|
||||
): Promise<{ success: boolean, orderId?: string, error?: string }> {
|
||||
): Promise<{ success: boolean, orderId?: string, tradeId?: string, error?: string }> {
|
||||
if (healthTracker.isPaused()) {
|
||||
logger.info(`[TradeExecutor] 🛑 Entry BLOCKED for ${symbol}: Bot is PAUSED by admin.`);
|
||||
return { success: false, error: 'Trade execution is paused by administrator' };
|
||||
@ -924,7 +954,7 @@ export class TradeExecutor {
|
||||
const ledgerProfileId = this.getLedgerProfileId();
|
||||
const tradeId = this.buildDeterministicTradeId(symbol, side);
|
||||
const finalUserId = userIdOverride || this.userId;
|
||||
let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price) : 0;
|
||||
let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price, type) : 0;
|
||||
let activeOrderId: string | undefined;
|
||||
let pendingCaptured = false;
|
||||
let capitalReserved = false;
|
||||
@ -961,15 +991,19 @@ export class TradeExecutor {
|
||||
logger.warn(`[EntryLock] Entry locked for ${symbol} (profile=${ledgerProfileId})`);
|
||||
return { success: false, error: 'Entry already running for this profile and symbol' };
|
||||
}
|
||||
lockOwner = ownerCandidate;
|
||||
lockAcquired = true;
|
||||
const lifecycleActive = await entryLockService.isEntryInProgress(ledgerProfileId, normalizedSymbol);
|
||||
if (lifecycleActive) {
|
||||
await releaseLockIfHeld();
|
||||
logger.warn(`[Executor] Entry lifecycle already open for ${symbol} (profile=${ledgerProfileId})`);
|
||||
return { success: false, error: 'Entry lifecycle already exists' };
|
||||
}
|
||||
if (await this.hasActiveTradeId(tradeId)) {
|
||||
lockOwner = ownerCandidate;
|
||||
lockAcquired = true;
|
||||
const lifecycleActive = await entryLockService.isEntryInProgress(ledgerProfileId, normalizedSymbol);
|
||||
if (lifecycleActive) {
|
||||
const corroborated = await this.hasOpenLifecycleEvidence(ledgerProfileId, normalizedSymbol);
|
||||
if (corroborated) {
|
||||
await releaseLockIfHeld();
|
||||
logger.warn(`[Executor] Entry lifecycle already open for ${symbol} (profile=${ledgerProfileId})`);
|
||||
return { success: false, error: 'Entry lifecycle already exists' };
|
||||
}
|
||||
logger.warn(`[Executor] Ignoring stale entry lifecycle marker for ${symbol} (profile=${ledgerProfileId})`);
|
||||
}
|
||||
if (await this.hasActiveTradeId(tradeId)) {
|
||||
await releaseLockIfHeld();
|
||||
logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`);
|
||||
return { success: false, error: 'Duplicate entry request blocked (idempotency window)' };
|
||||
@ -978,7 +1012,7 @@ export class TradeExecutor {
|
||||
const numericAvailable = Number.isFinite(Number(available ?? 0)) ? Number(available ?? 0) : 0;
|
||||
if (side === SignalDirection.BUY) {
|
||||
const requestedBeforeClamp = executionQty;
|
||||
const clampedQty = this.clampBuyQtyToAvailableCapital(symbol, executionQty, price, numericAvailable);
|
||||
const clampedQty = this.clampBuyQtyToAvailableCapital(symbol, executionQty, price, numericAvailable, type);
|
||||
if (!(clampedQty > 0)) {
|
||||
await releaseLockIfHeld();
|
||||
logger.warn(`[Executor] Entry blocked for ${symbol}: unable to size BUY within available capital (${numericAvailable}).`);
|
||||
@ -998,7 +1032,7 @@ export class TradeExecutor {
|
||||
executionQty = clampedQty;
|
||||
}
|
||||
}
|
||||
reservedEstimate = this.estimateOrderCost(symbol, executionQty, price);
|
||||
reservedEstimate = this.estimateOrderCost(symbol, executionQty, price, type);
|
||||
capitalReservationAmount = reservedEstimate;
|
||||
if (numericAvailable < reservedEstimate) {
|
||||
await releaseLockIfHeld();
|
||||
@ -1292,7 +1326,7 @@ export class TradeExecutor {
|
||||
const allPos = this.getAllActivePositions();
|
||||
this.apiServer.updatePositions(allPos, this.profileId || 'global');
|
||||
}
|
||||
return { success: true, orderId: order.id };
|
||||
return { success: true, orderId: order.id, tradeId };
|
||||
|
||||
} catch (error: any) {
|
||||
await releaseCapitalOnAbort();
|
||||
|
||||
@ -22,12 +22,19 @@ function isCosmosConfigured(): boolean {
|
||||
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
|
||||
}
|
||||
|
||||
function sanitizeLockToken(value: string): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || 'global';
|
||||
}
|
||||
|
||||
function buildEntryLockId(profileId: string, symbol?: string): string {
|
||||
return `entry:${profileId}:${String(symbol || '').trim()}`;
|
||||
return `entry:${sanitizeLockToken(profileId)}:${sanitizeLockToken(String(symbol || '').trim())}`;
|
||||
}
|
||||
|
||||
function buildReconciliationLockId(profileId: string): string {
|
||||
return `reconciliation:${profileId}`;
|
||||
return `reconciliation:${sanitizeLockToken(profileId)}`;
|
||||
}
|
||||
|
||||
async function readLock(id: string): Promise<RuntimeLockDocument | null> {
|
||||
|
||||
@ -23,6 +23,8 @@ export interface ManualEntryRecord {
|
||||
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;
|
||||
@ -74,6 +76,8 @@ function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, exist
|
||||
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: String(input.status || existing?.status || 'active'),
|
||||
|
||||
@ -13,6 +13,8 @@ export interface ManualEntryPayload {
|
||||
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;
|
||||
|
||||
@ -7,7 +7,9 @@ describe('SimpleView helpers', () => {
|
||||
draft: {
|
||||
symbol: 'aapl',
|
||||
side: 'buy',
|
||||
sizingMode: 'quantity',
|
||||
quantity: '5',
|
||||
amountUsd: '',
|
||||
currentMarketPrice: '210.25',
|
||||
dropMode: 'dollar',
|
||||
dropValue: '12',
|
||||
@ -26,6 +28,8 @@ describe('SimpleView helpers', () => {
|
||||
is_real_trade: false,
|
||||
label: 'Simple',
|
||||
quantity: 5,
|
||||
amount_usd: null,
|
||||
sizing_mode: 'quantity',
|
||||
filled_quantity: null,
|
||||
notes: 'Long-term compounder',
|
||||
entry_price: null,
|
||||
@ -50,7 +54,9 @@ describe('SimpleView helpers', () => {
|
||||
draft: {
|
||||
symbol: 'msft',
|
||||
side: 'sell',
|
||||
sizingMode: 'quantity',
|
||||
quantity: '1',
|
||||
amountUsd: '',
|
||||
currentMarketPrice: '420.50',
|
||||
dropMode: 'percent',
|
||||
dropValue: '',
|
||||
@ -76,6 +82,8 @@ describe('SimpleView helpers', () => {
|
||||
is_real_trade: false,
|
||||
label: 'Simple',
|
||||
quantity: 10,
|
||||
amount_usd: null,
|
||||
sizing_mode: 'quantity',
|
||||
filled_quantity: 10,
|
||||
notes: null,
|
||||
entry_price: 380.25,
|
||||
@ -95,12 +103,61 @@ describe('SimpleView helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('builds an amount-sized buy setup payload', () => {
|
||||
const payload = buildSimpleSetupPayload({
|
||||
draft: {
|
||||
symbol: 'btc/usd',
|
||||
side: 'buy',
|
||||
sizingMode: 'amount',
|
||||
quantity: '',
|
||||
amountUsd: '250',
|
||||
currentMarketPrice: '81366.105',
|
||||
dropMode: 'percent',
|
||||
dropValue: '0',
|
||||
profitMode: 'dollar',
|
||||
profitValue: '25',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
stock_instance_id: undefined,
|
||||
symbol: 'BTC/USD',
|
||||
active: true,
|
||||
status: 'simple_armed_buy',
|
||||
is_crypto: false,
|
||||
is_real_trade: false,
|
||||
label: 'Simple',
|
||||
quantity: null,
|
||||
amount_usd: 250,
|
||||
sizing_mode: 'amount',
|
||||
filled_quantity: null,
|
||||
notes: null,
|
||||
entry_price: null,
|
||||
reference_price: 81366.105,
|
||||
gain_threshold_for_sell: 25,
|
||||
drop_threshold_for_buy: 0,
|
||||
workflow_type: 'simple',
|
||||
simple_side: 'buy',
|
||||
drop_trigger_mode: 'percent',
|
||||
profit_target_mode: 'dollar',
|
||||
linked_trade_id: null,
|
||||
profile_id: null,
|
||||
buy_price: null,
|
||||
sell_price: null,
|
||||
buy_time: null,
|
||||
sell_time: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects sell setups without an existing holding', () => {
|
||||
expect(() => buildSimpleSetupPayload({
|
||||
draft: {
|
||||
symbol: 'nvda',
|
||||
side: 'sell',
|
||||
sizingMode: 'quantity',
|
||||
quantity: '5',
|
||||
amountUsd: '',
|
||||
currentMarketPrice: '900',
|
||||
dropMode: 'percent',
|
||||
dropValue: '',
|
||||
|
||||
@ -23,7 +23,9 @@ type TriggerMode = 'dollar' | 'percent';
|
||||
type SimpleSetupDraft = {
|
||||
symbol: string;
|
||||
side: SimpleSide;
|
||||
sizingMode: 'quantity' | 'amount';
|
||||
quantity: string;
|
||||
amountUsd: string;
|
||||
currentMarketPrice: string;
|
||||
dropMode: TriggerMode;
|
||||
dropValue: string;
|
||||
@ -42,11 +44,27 @@ type SimpleHolding = {
|
||||
|
||||
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
||||
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
||||
const SIMPLE_SYMBOL_DATALIST_ID = 'simple-supported-symbols';
|
||||
const COMMON_SIMPLE_SYMBOLS = [
|
||||
'AAPL',
|
||||
'MSFT',
|
||||
'NVDA',
|
||||
'TSLA',
|
||||
'SPY',
|
||||
'QQQ',
|
||||
'AMZN',
|
||||
'META',
|
||||
'BTC/USD',
|
||||
'ETH/USD',
|
||||
'SOL/USD',
|
||||
];
|
||||
|
||||
const DEFAULT_DRAFT: SimpleSetupDraft = {
|
||||
symbol: '',
|
||||
side: 'buy',
|
||||
sizingMode: 'quantity',
|
||||
quantity: '',
|
||||
amountUsd: '',
|
||||
currentMarketPrice: '',
|
||||
dropMode: 'percent',
|
||||
dropValue: '',
|
||||
@ -127,9 +145,7 @@ export function buildSimpleSetupPayload(input: {
|
||||
}
|
||||
|
||||
const quantity = parsePositiveNumber(input.draft.quantity);
|
||||
if (!quantity) {
|
||||
throw new Error('Quantity is required');
|
||||
}
|
||||
const amountUsd = parsePositiveNumber(input.draft.amountUsd);
|
||||
|
||||
const profitValue = parsePositiveNumber(input.draft.profitValue);
|
||||
if (!profitValue) {
|
||||
@ -147,6 +163,16 @@ export function buildSimpleSetupPayload(input: {
|
||||
throw new Error('Drop trigger is required for buy setups');
|
||||
}
|
||||
|
||||
if (side === 'buy') {
|
||||
if (input.draft.sizingMode === 'amount') {
|
||||
if (!amountUsd) {
|
||||
throw new Error('USD amount is required');
|
||||
}
|
||||
} else if (!quantity) {
|
||||
throw new Error('Quantity is required');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stock_instance_id: input.existingId,
|
||||
symbol,
|
||||
@ -155,7 +181,9 @@ export function buildSimpleSetupPayload(input: {
|
||||
is_crypto: false,
|
||||
is_real_trade: false,
|
||||
label: 'Simple',
|
||||
quantity: side === 'sell' ? holding!.size : quantity,
|
||||
quantity: side === 'sell' ? holding!.size : (input.draft.sizingMode === 'quantity' ? quantity : null),
|
||||
amount_usd: side === 'buy' && input.draft.sizingMode === 'amount' ? amountUsd : null,
|
||||
sizing_mode: side === 'buy' ? input.draft.sizingMode : 'quantity',
|
||||
filled_quantity: side === 'sell' ? holding!.size : null,
|
||||
notes: input.draft.notes.trim() || null,
|
||||
entry_price: side === 'sell' ? holding!.entryPrice : null,
|
||||
@ -194,9 +222,12 @@ function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null
|
||||
const profitText = draft.profitMode === 'dollar'
|
||||
? `$${Number(draft.profitValue || 0).toFixed(2)} above purchase`
|
||||
: `${draft.profitValue || '0'}% above purchase`;
|
||||
const sizeText = draft.sizingMode === 'amount'
|
||||
? `Spend $${Number(draft.amountUsd || 0).toFixed(2)}`
|
||||
: `Buy ${draft.quantity || '0'} units`;
|
||||
|
||||
return [
|
||||
`Buy ${symbol} when price reaches ${triggerPrice.toFixed(4)} (${dropText}).`,
|
||||
`${sizeText} of ${symbol} when price reaches ${triggerPrice.toFixed(4)} (${dropText}).`,
|
||||
profitTargetPrice ? `Exit target stays armed at ${profitTargetPrice.toFixed(4)} (${profitText}).` : `Exit target uses ${profitText}.`,
|
||||
].join(' ');
|
||||
}
|
||||
@ -216,11 +247,38 @@ function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null
|
||||
return `Exit ${symbol} when price reaches ${profitTargetPrice.toFixed(4)} (${profitText}).`;
|
||||
}
|
||||
|
||||
function deriveRuntimeOrderStatus(
|
||||
entry: ManualEntryPayload,
|
||||
orders: Array<Record<string, any>>,
|
||||
holdings: SimpleHolding[],
|
||||
): string | null {
|
||||
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
||||
if (linkedTradeId) {
|
||||
const matchingOrder = orders.find((order) => String(order?.trade_id || order?.tradeId || '').trim() === linkedTradeId);
|
||||
if (matchingOrder) {
|
||||
const status = String(matchingOrder.status || '').trim();
|
||||
return status ? `Order: ${status}` : null;
|
||||
}
|
||||
}
|
||||
|
||||
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
||||
const side = normalizeSetupSide(entry.simple_side);
|
||||
if (side === 'buy' && holdings.some((holding) => holding.symbol === symbol)) {
|
||||
return 'Portfolio: open holding';
|
||||
}
|
||||
if (String(entry.status || '').trim().toLowerCase() === 'sellcompleted') {
|
||||
return 'Portfolio: closed';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDraftFromEntry(entry: ManualEntryPayload): SimpleSetupDraft {
|
||||
return {
|
||||
symbol: String(entry.symbol || '').trim().toUpperCase(),
|
||||
side: normalizeSetupSide(entry.simple_side),
|
||||
sizingMode: String(entry.sizing_mode || '').trim().toLowerCase() === 'amount' ? 'amount' : 'quantity',
|
||||
quantity: entry.quantity ? String(entry.quantity) : '',
|
||||
amountUsd: entry.amount_usd ? String(entry.amount_usd) : '',
|
||||
currentMarketPrice: entry.reference_price ? Number(entry.reference_price).toFixed(4) : '',
|
||||
dropMode: normalizeMode(entry.drop_trigger_mode, 'percent'),
|
||||
dropValue: entry.drop_threshold_for_buy !== null && entry.drop_threshold_for_buy !== undefined ? String(entry.drop_threshold_for_buy) : '',
|
||||
@ -260,6 +318,11 @@ function normalizeSimpleEntries(entries: ManualEntryPayload[]): ManualEntryPaylo
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeKnownSymbol(value: unknown): string | null {
|
||||
const normalized = String(value || '').trim().toUpperCase();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function describeSavedSetup(entry: ManualEntryPayload): string {
|
||||
const side = normalizeSetupSide(entry.simple_side);
|
||||
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
||||
@ -269,6 +332,10 @@ function describeSavedSetup(entry: ManualEntryPayload): string {
|
||||
const profitMode = normalizeMode(entry.profit_target_mode, 'percent');
|
||||
const referencePrice = Number(entry.reference_price || 0);
|
||||
const entryPrice = Number(entry.entry_price || 0);
|
||||
const sizingMode = String(entry.sizing_mode || 'quantity').trim().toLowerCase();
|
||||
const sizeText = sizingMode === 'amount'
|
||||
? `$${Number(entry.amount_usd || 0).toFixed(2)} budget`
|
||||
: `${Number(entry.quantity || 0).toFixed(6).replace(/\.?0+$/, '')} units`;
|
||||
|
||||
if (side === 'buy') {
|
||||
const triggerPrice = computeBuyTriggerPrice(buildDraftFromEntry(entry));
|
||||
@ -280,14 +347,14 @@ function describeSavedSetup(entry: ManualEntryPayload): string {
|
||||
const profitText = profitMode === 'dollar'
|
||||
? `$${profitValue.toFixed(2)} above purchase`
|
||||
: `${profitValue}% above purchase`;
|
||||
return `Buy ${symbol} ${dropText} ${referencePrice > 0 ? `(${referencePrice.toFixed(4)} ref` : ''}${triggerPrice ? ` → ${triggerPrice.toFixed(4)}` : ''}). Exit at ${profitText}.`;
|
||||
return `Buy ${symbol} using ${sizeText} ${dropText} ${referencePrice > 0 ? `(${referencePrice.toFixed(4)} ref` : ''}${triggerPrice ? ` → ${triggerPrice.toFixed(4)}` : ''}). Exit at ${profitText}.`;
|
||||
}
|
||||
|
||||
const profitTargetPrice = computeProfitTargetPrice(entryPrice || null, profitMode, String(profitValue || ''));
|
||||
const profitText = profitMode === 'dollar'
|
||||
? `$${profitValue.toFixed(2)} above purchase`
|
||||
: `${profitValue}% above purchase`;
|
||||
return `Exit ${symbol} at ${profitText}${profitTargetPrice ? ` (${profitTargetPrice.toFixed(4)})` : ''}.`;
|
||||
return `Exit full ${symbol} holding at ${profitText}${profitTargetPrice ? ` (${profitTargetPrice.toFixed(4)})` : ''}.`;
|
||||
}
|
||||
|
||||
export function SimpleView() {
|
||||
@ -322,11 +389,36 @@ export function SimpleView() {
|
||||
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0);
|
||||
}, [botState.positions, simpleAutoProfile?.id]);
|
||||
|
||||
const runtimeOrders = useMemo(() => {
|
||||
return Array.from(botState.orders.values()).filter((order) => {
|
||||
if (!simpleAutoProfile?.id) return true;
|
||||
return String(order.profileId || '').trim() === simpleAutoProfile.id;
|
||||
});
|
||||
}, [botState.orders, simpleAutoProfile?.id]);
|
||||
|
||||
const matchingHolding = useMemo(
|
||||
() => simpleHoldings.find((holding) => holding.symbol === normalizedSymbol) || null,
|
||||
[simpleHoldings, normalizedSymbol],
|
||||
);
|
||||
|
||||
const supportedSymbols = useMemo(() => {
|
||||
const fromState = Object.keys(botState.symbols || {}).map(normalizeKnownSymbol).filter(Boolean) as string[];
|
||||
const fromSetups = savedSetups.map((entry) => normalizeKnownSymbol(entry.symbol)).filter(Boolean) as string[];
|
||||
const fromHoldings = simpleHoldings.map((holding) => normalizeKnownSymbol(holding.symbol)).filter(Boolean) as string[];
|
||||
return Array.from(new Set([...fromState, ...fromSetups, ...fromHoldings, ...COMMON_SIMPLE_SYMBOLS])).sort((left, right) => left.localeCompare(right));
|
||||
}, [botState.symbols, savedSetups, simpleHoldings]);
|
||||
|
||||
const filteredSymbolSuggestions = useMemo(() => {
|
||||
if (!normalizedSymbol) {
|
||||
return supportedSymbols.slice(0, 8);
|
||||
}
|
||||
const startsWith = supportedSymbols.filter((symbol) => symbol.startsWith(normalizedSymbol));
|
||||
const includes = supportedSymbols.filter((symbol) => !symbol.startsWith(normalizedSymbol) && symbol.includes(normalizedSymbol));
|
||||
return [...startsWith, ...includes].slice(0, 8);
|
||||
}, [normalizedSymbol, supportedSymbols]);
|
||||
|
||||
const isSuggestedSymbol = normalizedSymbol ? supportedSymbols.includes(normalizedSymbol) : false;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@ -518,8 +610,44 @@ export function SimpleView() {
|
||||
symbol: e.target.value.toUpperCase(),
|
||||
currentMarketPrice: '',
|
||||
}))}
|
||||
list={SIMPLE_SYMBOL_DATALIST_ID}
|
||||
placeholder="AAPL"
|
||||
/>
|
||||
<datalist id={SIMPLE_SYMBOL_DATALIST_ID}>
|
||||
{supportedSymbols.map((symbol) => (
|
||||
<option key={symbol} value={symbol} />
|
||||
))}
|
||||
</datalist>
|
||||
<span className="block text-[11px] text-[var(--muted-foreground)]">
|
||||
Start typing to pick a supported symbol. Suggestions come from live market symbols plus common supported assets.
|
||||
</span>
|
||||
{normalizedSymbol && !isSuggestedSymbol ? (
|
||||
<span className="block text-[11px] text-amber-700 dark:text-amber-300">
|
||||
This symbol is not in the current supported suggestions. Double-check it before saving.
|
||||
</span>
|
||||
) : null}
|
||||
{filteredSymbolSuggestions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{filteredSymbolSuggestions.map((symbol) => (
|
||||
<button
|
||||
key={symbol}
|
||||
type="button"
|
||||
onClick={() => setDraft((prev) => ({
|
||||
...prev,
|
||||
symbol,
|
||||
currentMarketPrice: '',
|
||||
}))}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition ${
|
||||
symbol === normalizedSymbol
|
||||
? 'border-[var(--primary)] bg-[var(--accent-soft)] text-[var(--primary)]'
|
||||
: 'border-[var(--border)] bg-[var(--card-elevated)] text-[var(--muted-foreground)] hover:border-[var(--primary)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
{symbol}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
@ -561,18 +689,53 @@ export function SimpleView() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
|
||||
{draft.side === 'buy' ? 'Planned quantity' : 'Holding size'}
|
||||
</span>
|
||||
<Input
|
||||
value={draft.side === 'sell' && matchingHolding ? String(matchingHolding.size) : draft.quantity}
|
||||
onChange={(e) => updateDraft('quantity', e.target.value)}
|
||||
readOnly={draft.side === 'sell' && !!matchingHolding}
|
||||
className="read-only:bg-[var(--muted)]"
|
||||
placeholder="10"
|
||||
/>
|
||||
</label>
|
||||
{draft.side === 'buy' ? (
|
||||
<>
|
||||
<label className="space-y-2">
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Sizing method</span>
|
||||
<Select
|
||||
value={draft.sizingMode}
|
||||
onChange={(e) => updateDraft('sizingMode', e.target.value as 'quantity' | 'amount')}
|
||||
>
|
||||
<option value="quantity">Quantity / fractional shares</option>
|
||||
<option value="amount">USD amount</option>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
|
||||
{draft.sizingMode === 'amount' ? 'Spend amount (USD)' : 'Planned quantity'}
|
||||
</span>
|
||||
<Input
|
||||
value={draft.sizingMode === 'amount' ? draft.amountUsd : draft.quantity}
|
||||
onChange={(e) => {
|
||||
if (draft.sizingMode === 'amount') {
|
||||
updateDraft('amountUsd', e.target.value);
|
||||
} else {
|
||||
updateDraft('quantity', e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={draft.sizingMode === 'amount' ? '250' : '10'}
|
||||
/>
|
||||
<span className="block text-[11px] text-[var(--muted-foreground)]">
|
||||
Quantity supports fractional shares/coins. Amount spends an approximate USD budget at trigger time.
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="space-y-2">
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
|
||||
Holding size
|
||||
</span>
|
||||
<Input
|
||||
value={draft.side === 'sell' && matchingHolding ? String(matchingHolding.size) : draft.quantity}
|
||||
onChange={(e) => updateDraft('quantity', e.target.value)}
|
||||
readOnly={draft.side === 'sell' && !!matchingHolding}
|
||||
className="read-only:bg-[var(--muted)]"
|
||||
placeholder="10"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Notes</span>
|
||||
@ -639,7 +802,7 @@ export function SimpleView() {
|
||||
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
{matchingHolding
|
||||
? `Simple holding ready: ${matchingHolding.symbol} · ${matchingHolding.size} shares at ${matchingHolding.entryPrice.toFixed(4)}`
|
||||
? `Simple holding ready: ${matchingHolding.symbol} · ${matchingHolding.size} units at ${matchingHolding.entryPrice.toFixed(4)}. Executed Simple buys also appear in Portfolio as live positions.`
|
||||
: 'No existing Simple holding found for this symbol. Sell setups only arm against a current Simple holding.'}
|
||||
</div>
|
||||
)}
|
||||
@ -677,7 +840,7 @@ export function SimpleView() {
|
||||
<Card>
|
||||
<CardHeader className="block">
|
||||
<CardTitle className="uppercase">Saved setups</CardTitle>
|
||||
<CardDescription>Review and update armed simple workflows in the same layout style used across the app.</CardDescription>
|
||||
<CardDescription>Review armed setups, current runtime order state, and whether an executed setup is now showing in Portfolio as an open holding.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
@ -692,6 +855,7 @@ export function SimpleView() {
|
||||
const entryId = String(entry.stock_instance_id || '');
|
||||
const side = normalizeSetupSide(entry.simple_side);
|
||||
const isEditing = editingSetupId === entryId;
|
||||
const runtimeStatus = deriveRuntimeOrderStatus(entry, runtimeOrders, simpleHoldings);
|
||||
return (
|
||||
<div key={entryId} className="rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5">
|
||||
<div className="mb-3 flex items-start justify-between gap-4">
|
||||
@ -753,6 +917,11 @@ export function SimpleView() {
|
||||
Trade linked
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeStatus ? (
|
||||
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||
{runtimeStatus}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user