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',
|
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_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_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'),
|
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_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'),
|
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),
|
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_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_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),
|
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_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),
|
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 {
|
export class AlpacaConnector implements IExchangeConnector {
|
||||||
private client: any;
|
private client: any;
|
||||||
|
|
||||||
constructor(apiKey?: string, apiSecret?: string) {
|
constructor(apiKey?: string, apiSecret?: string, options?: { paper?: boolean }) {
|
||||||
this.client = new (Alpaca as any)({
|
this.client = new (Alpaca as any)({
|
||||||
keyId: apiKey || config.ALPACA_API_KEY,
|
keyId: apiKey || config.ALPACA_API_KEY,
|
||||||
secretKey: apiSecret || config.ALPACA_API_SECRET,
|
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 { CCXTConnector } from './ccxt.js';
|
||||||
import { IExchangeConnector } from './types.js';
|
import { IExchangeConnector } from './types.js';
|
||||||
|
|
||||||
export class ConnectorFactory {
|
export class ConnectorFactory {
|
||||||
static getConnector(): IExchangeConnector {
|
static getConnector(): IExchangeConnector {
|
||||||
return this.getCustomConnector(config.PROVIDER);
|
return this.getCustomConnector(config.PROVIDER);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getCustomConnector(provider: string, apiKey?: string, apiSecret?: string): IExchangeConnector {
|
static getCustomConnector(
|
||||||
|
provider: string,
|
||||||
|
apiKey?: string,
|
||||||
|
apiSecret?: string,
|
||||||
|
options?: { paper?: boolean }
|
||||||
|
): IExchangeConnector {
|
||||||
switch (provider.toLowerCase()) {
|
switch (provider.toLowerCase()) {
|
||||||
case 'alpaca':
|
case 'alpaca':
|
||||||
return new AlpacaConnector(apiKey, apiSecret);
|
return new AlpacaConnector(apiKey, apiSecret, options);
|
||||||
case 'ccxt':
|
case 'ccxt':
|
||||||
return new CCXTConnector(apiKey, apiSecret);
|
return new CCXTConnector(apiKey, apiSecret);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -36,6 +36,17 @@ const toNonNegativeNumber = (value: unknown): number | null => {
|
|||||||
return Number.isFinite(numeric) && numeric >= 0 ? numeric : 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 normalizeTriggerMode = (value: unknown): 'dollar' | 'percent' | null => {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
if (normalized === 'dollar' || normalized === 'percent') return normalized;
|
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(', ')}`);
|
logger.info(`🚨 Bot Initialized for ${config.SYMBOLS.length} Symbols: ${config.SYMBOLS.join(', ')}`);
|
||||||
|
|
||||||
// --- 0. Initialize Modular Exchanges ---
|
// --- 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);
|
const apiServer = new ApiServer(config.API_PORT);
|
||||||
apiServer.pruneSymbols(config.SYMBOLS);
|
apiServer.pruneSymbols(config.SYMBOLS);
|
||||||
const proEngine = new ProStrategyEngine(dataExchange);
|
const proEngine = new ProStrategyEngine(dataExchange);
|
||||||
@ -263,9 +283,10 @@ async function main() {
|
|||||||
if (shouldArmSimpleBuy(entry)) {
|
if (shouldArmSimpleBuy(entry)) {
|
||||||
const currentPrice = resolveSimpleMarketPrice(entry) ?? toPositiveNumber(entry.reference_price);
|
const currentPrice = resolveSimpleMarketPrice(entry) ?? toPositiveNumber(entry.reference_price);
|
||||||
const triggerPrice = computeSimpleBuyTriggerPrice(entry);
|
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);
|
const threshold = toNonNegativeNumber(entry.drop_threshold_for_buy);
|
||||||
if (!triggerPrice || !desiredQty || !(currentPrice && currentPrice > 0)) continue;
|
if (desiredQty === null) continue;
|
||||||
if (currentPrice > triggerPrice) continue;
|
if (currentPrice > triggerPrice) continue;
|
||||||
|
|
||||||
const result = await ctx.manualTrader.executeRequest(
|
const result = await ctx.manualTrader.executeRequest(
|
||||||
@ -285,6 +306,7 @@ async function main() {
|
|||||||
await saveManualEntryForUser(entry.user_id, {
|
await saveManualEntryForUser(entry.user_id, {
|
||||||
...entry,
|
...entry,
|
||||||
profile_id: entry.profile_id || ctx.profileId,
|
profile_id: entry.profile_id || ctx.profileId,
|
||||||
|
linked_trade_id: result.tradeId || entry.linked_trade_id,
|
||||||
filled_quantity: result.adjustedQty ?? entry.filled_quantity,
|
filled_quantity: result.adjustedQty ?? entry.filled_quantity,
|
||||||
buy_time: new Date().toISOString(),
|
buy_time: new Date().toISOString(),
|
||||||
status: 'simple_entry_submitted',
|
status: 'simple_entry_submitted',
|
||||||
@ -491,7 +513,12 @@ async function main() {
|
|||||||
return null;
|
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);
|
const userExecutor = new TradeExecutor(userExecExchange, apiServer, user.user_id, profile.id);
|
||||||
userExecutor.setProfileSettings(profile);
|
userExecutor.setProfileSettings(profile);
|
||||||
const monitoredSymbols = resolveProfileSymbols(profile);
|
const monitoredSymbols = resolveProfileSymbols(profile);
|
||||||
@ -572,7 +599,12 @@ async function main() {
|
|||||||
if (!userKey || !userSecret) continue;
|
if (!userKey || !userSecret) continue;
|
||||||
|
|
||||||
const defaultProfileId = `default-${user.user_id}`;
|
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);
|
const userExecutor = new TradeExecutor(userExecExchange, apiServer, user.user_id, defaultProfileId);
|
||||||
await userExecutor.syncPositions(config.SYMBOLS);
|
await userExecutor.syncPositions(config.SYMBOLS);
|
||||||
await userExecutor.rebuildStartupState();
|
await userExecutor.rebuildStartupState();
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export class ManualTrader {
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
sl?: number,
|
sl?: number,
|
||||||
tp?: 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 signalSide = (side.toLowerCase() === 'buy') ? SignalDirection.BUY : SignalDirection.SELL;
|
||||||
const requestedQty = Number(qty);
|
const requestedQty = Number(qty);
|
||||||
if (!Number.isFinite(requestedQty) || requestedQty <= 0) {
|
if (!Number.isFinite(requestedQty) || requestedQty <= 0) {
|
||||||
@ -120,7 +120,7 @@ export class ManualTrader {
|
|||||||
signalSide,
|
signalSide,
|
||||||
adjustedQty,
|
adjustedQty,
|
||||||
type as 'market' | 'limit',
|
type as 'market' | 'limit',
|
||||||
price,
|
type === 'market' ? estimatedPrice : price,
|
||||||
sl,
|
sl,
|
||||||
tp,
|
tp,
|
||||||
userId
|
userId
|
||||||
|
|||||||
@ -410,11 +410,34 @@ export class TradeExecutor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hasActiveTradeId(tradeId?: string): Promise<boolean> {
|
private async hasActiveTradeId(tradeId?: string): Promise<boolean> {
|
||||||
const normalized = String(tradeId || '').trim();
|
const normalized = String(tradeId || '').trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
return await runtimeOrderRepository.hasActiveOrderForTradeId(normalized, this.profileId);
|
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> {
|
private async isTradeAlreadyFinalized(tradeId?: string): Promise<boolean> {
|
||||||
const normalized = String(tradeId || '').trim();
|
const normalized = String(tradeId || '').trim();
|
||||||
@ -496,13 +519,19 @@ export class TradeExecutor {
|
|||||||
return config.ENABLE_STRICT_CAPITAL_GUARD !== false;
|
return config.ENABLE_STRICT_CAPITAL_GUARD !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private strictCapitalCostMultiplier(): number {
|
private strictCapitalCostMultiplier(orderType?: 'market' | 'limit', symbol?: string): number {
|
||||||
if (!this.isStrictCapitalGuardEnabled()) return 1;
|
if (!this.isStrictCapitalGuardEnabled()) return 1;
|
||||||
const slippagePct = Math.max(0, Number(config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || 0));
|
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 feePct = Math.max(0, Number(config.STRICT_CAPITAL_FEE_BUFFER_PCT || 0));
|
||||||
const multiplier = 1 + ((slippagePct + feePct) / 100);
|
const normalizedSymbol = String(symbol || '').trim().toUpperCase();
|
||||||
return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1;
|
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 {
|
private strictCapitalMinReserveUsd(): number {
|
||||||
if (!this.isStrictCapitalGuardEnabled()) return 0;
|
if (!this.isStrictCapitalGuardEnabled()) return 0;
|
||||||
@ -510,12 +539,13 @@ export class TradeExecutor {
|
|||||||
return Number.isFinite(reserve) && reserve > 0 ? reserve : 0;
|
return Number.isFinite(reserve) && reserve > 0 ? reserve : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private clampBuyQtyToAvailableCapital(
|
private clampBuyQtyToAvailableCapital(
|
||||||
symbol: string,
|
symbol: string,
|
||||||
requestedQty: number,
|
requestedQty: number,
|
||||||
requestedPrice: number | undefined,
|
requestedPrice: number | undefined,
|
||||||
availableCapital: number
|
availableCapital: number,
|
||||||
): number {
|
orderType: 'market' | 'limit' = 'market'
|
||||||
|
): number {
|
||||||
if (!Number.isFinite(requestedQty) || requestedQty <= 0) return 0;
|
if (!Number.isFinite(requestedQty) || requestedQty <= 0) return 0;
|
||||||
if (!Number.isFinite(availableCapital) || availableCapital <= 0) return 0;
|
if (!Number.isFinite(availableCapital) || availableCapital <= 0) return 0;
|
||||||
|
|
||||||
@ -527,7 +557,7 @@ export class TradeExecutor {
|
|||||||
const unitPrice = this.resolveOrderReferencePrice(symbol, requestedPrice);
|
const unitPrice = this.resolveOrderReferencePrice(symbol, requestedPrice);
|
||||||
if (!(unitPrice > 0)) return 0;
|
if (!(unitPrice > 0)) return 0;
|
||||||
|
|
||||||
const effectiveUnitCost = unitPrice * this.strictCapitalCostMultiplier();
|
const effectiveUnitCost = unitPrice * this.strictCapitalCostMultiplier(orderType, symbol);
|
||||||
if (!(effectiveUnitCost > 0)) return 0;
|
if (!(effectiveUnitCost > 0)) return 0;
|
||||||
const maxQty = this.roundDownQty(budget / effectiveUnitCost);
|
const maxQty = this.roundDownQty(budget / effectiveUnitCost);
|
||||||
if (!(maxQty > 0)) return 0;
|
if (!(maxQty > 0)) return 0;
|
||||||
@ -606,10 +636,10 @@ export class TradeExecutor {
|
|||||||
return Number(Math.max(notional, minimum));
|
return Number(Math.max(notional, minimum));
|
||||||
}
|
}
|
||||||
|
|
||||||
private estimateOrderCost(symbol: string, qty: number, price?: number): number {
|
private estimateOrderCost(symbol: string, qty: number, price?: number, orderType: 'market' | 'limit' = 'market'): number {
|
||||||
if (!Number.isFinite(qty) || qty <= 0) return 0;
|
if (!Number.isFinite(qty) || qty <= 0) return 0;
|
||||||
const unitPrice = this.resolveOrderReferencePrice(symbol, price);
|
const unitPrice = this.resolveOrderReferencePrice(symbol, price);
|
||||||
const notional = qty * unitPrice * this.strictCapitalCostMultiplier();
|
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 minimum = Number.isFinite(config.MIN_NOTIONAL_USD) && config.MIN_NOTIONAL_USD > 0 ? config.MIN_NOTIONAL_USD : 0;
|
||||||
const base = Math.max(notional, minimum);
|
const base = Math.max(notional, minimum);
|
||||||
return Number(base + this.strictCapitalMinReserveUsd());
|
return Number(base + this.strictCapitalMinReserveUsd());
|
||||||
@ -911,7 +941,7 @@ export class TradeExecutor {
|
|||||||
sl?: number,
|
sl?: number,
|
||||||
tp?: number,
|
tp?: number,
|
||||||
userIdOverride?: string
|
userIdOverride?: string
|
||||||
): Promise<{ success: boolean, orderId?: string, error?: string }> {
|
): Promise<{ success: boolean, orderId?: string, tradeId?: string, error?: string }> {
|
||||||
if (healthTracker.isPaused()) {
|
if (healthTracker.isPaused()) {
|
||||||
logger.info(`[TradeExecutor] 🛑 Entry BLOCKED for ${symbol}: Bot is PAUSED by admin.`);
|
logger.info(`[TradeExecutor] 🛑 Entry BLOCKED for ${symbol}: Bot is PAUSED by admin.`);
|
||||||
return { success: false, error: 'Trade execution is paused by administrator' };
|
return { success: false, error: 'Trade execution is paused by administrator' };
|
||||||
@ -924,7 +954,7 @@ export class TradeExecutor {
|
|||||||
const ledgerProfileId = this.getLedgerProfileId();
|
const ledgerProfileId = this.getLedgerProfileId();
|
||||||
const tradeId = this.buildDeterministicTradeId(symbol, side);
|
const tradeId = this.buildDeterministicTradeId(symbol, side);
|
||||||
const finalUserId = userIdOverride || this.userId;
|
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 activeOrderId: string | undefined;
|
||||||
let pendingCaptured = false;
|
let pendingCaptured = false;
|
||||||
let capitalReserved = false;
|
let capitalReserved = false;
|
||||||
@ -961,15 +991,19 @@ export class TradeExecutor {
|
|||||||
logger.warn(`[EntryLock] Entry locked for ${symbol} (profile=${ledgerProfileId})`);
|
logger.warn(`[EntryLock] Entry locked for ${symbol} (profile=${ledgerProfileId})`);
|
||||||
return { success: false, error: 'Entry already running for this profile and symbol' };
|
return { success: false, error: 'Entry already running for this profile and symbol' };
|
||||||
}
|
}
|
||||||
lockOwner = ownerCandidate;
|
lockOwner = ownerCandidate;
|
||||||
lockAcquired = true;
|
lockAcquired = true;
|
||||||
const lifecycleActive = await entryLockService.isEntryInProgress(ledgerProfileId, normalizedSymbol);
|
const lifecycleActive = await entryLockService.isEntryInProgress(ledgerProfileId, normalizedSymbol);
|
||||||
if (lifecycleActive) {
|
if (lifecycleActive) {
|
||||||
await releaseLockIfHeld();
|
const corroborated = await this.hasOpenLifecycleEvidence(ledgerProfileId, normalizedSymbol);
|
||||||
logger.warn(`[Executor] Entry lifecycle already open for ${symbol} (profile=${ledgerProfileId})`);
|
if (corroborated) {
|
||||||
return { success: false, error: 'Entry lifecycle already exists' };
|
await releaseLockIfHeld();
|
||||||
}
|
logger.warn(`[Executor] Entry lifecycle already open for ${symbol} (profile=${ledgerProfileId})`);
|
||||||
if (await this.hasActiveTradeId(tradeId)) {
|
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();
|
await releaseLockIfHeld();
|
||||||
logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`);
|
logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`);
|
||||||
return { success: false, error: 'Duplicate entry request blocked (idempotency window)' };
|
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;
|
const numericAvailable = Number.isFinite(Number(available ?? 0)) ? Number(available ?? 0) : 0;
|
||||||
if (side === SignalDirection.BUY) {
|
if (side === SignalDirection.BUY) {
|
||||||
const requestedBeforeClamp = executionQty;
|
const requestedBeforeClamp = executionQty;
|
||||||
const clampedQty = this.clampBuyQtyToAvailableCapital(symbol, executionQty, price, numericAvailable);
|
const clampedQty = this.clampBuyQtyToAvailableCapital(symbol, executionQty, price, numericAvailable, type);
|
||||||
if (!(clampedQty > 0)) {
|
if (!(clampedQty > 0)) {
|
||||||
await releaseLockIfHeld();
|
await releaseLockIfHeld();
|
||||||
logger.warn(`[Executor] Entry blocked for ${symbol}: unable to size BUY within available capital (${numericAvailable}).`);
|
logger.warn(`[Executor] Entry blocked for ${symbol}: unable to size BUY within available capital (${numericAvailable}).`);
|
||||||
@ -998,7 +1032,7 @@ export class TradeExecutor {
|
|||||||
executionQty = clampedQty;
|
executionQty = clampedQty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reservedEstimate = this.estimateOrderCost(symbol, executionQty, price);
|
reservedEstimate = this.estimateOrderCost(symbol, executionQty, price, type);
|
||||||
capitalReservationAmount = reservedEstimate;
|
capitalReservationAmount = reservedEstimate;
|
||||||
if (numericAvailable < reservedEstimate) {
|
if (numericAvailable < reservedEstimate) {
|
||||||
await releaseLockIfHeld();
|
await releaseLockIfHeld();
|
||||||
@ -1292,7 +1326,7 @@ export class TradeExecutor {
|
|||||||
const allPos = this.getAllActivePositions();
|
const allPos = this.getAllActivePositions();
|
||||||
this.apiServer.updatePositions(allPos, this.profileId || 'global');
|
this.apiServer.updatePositions(allPos, this.profileId || 'global');
|
||||||
}
|
}
|
||||||
return { success: true, orderId: order.id };
|
return { success: true, orderId: order.id, tradeId };
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await releaseCapitalOnAbort();
|
await releaseCapitalOnAbort();
|
||||||
|
|||||||
@ -22,12 +22,19 @@ function isCosmosConfigured(): boolean {
|
|||||||
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
|
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 {
|
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 {
|
function buildReconciliationLockId(profileId: string): string {
|
||||||
return `reconciliation:${profileId}`;
|
return `reconciliation:${sanitizeLockToken(profileId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readLock(id: string): Promise<RuntimeLockDocument | null> {
|
async function readLock(id: string): Promise<RuntimeLockDocument | null> {
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export interface ManualEntryRecord {
|
|||||||
buy_time?: string | null;
|
buy_time?: string | null;
|
||||||
sell_time?: string | null;
|
sell_time?: string | null;
|
||||||
quantity?: number | null;
|
quantity?: number | null;
|
||||||
|
amount_usd?: number | null;
|
||||||
|
sizing_mode?: string | null;
|
||||||
filled_quantity?: number | null;
|
filled_quantity?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
@ -74,6 +76,8 @@ function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, exist
|
|||||||
buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time),
|
buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time),
|
||||||
sell_time: normalizeNullableString(input.sell_time ?? existing?.sell_time),
|
sell_time: normalizeNullableString(input.sell_time ?? existing?.sell_time),
|
||||||
quantity: normalizeNullableNumber(input.quantity ?? existing?.quantity),
|
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),
|
filled_quantity: normalizeNullableNumber(input.filled_quantity ?? existing?.filled_quantity),
|
||||||
notes: normalizeNullableString(input.notes ?? existing?.notes),
|
notes: normalizeNullableString(input.notes ?? existing?.notes),
|
||||||
status: String(input.status || existing?.status || 'active'),
|
status: String(input.status || existing?.status || 'active'),
|
||||||
|
|||||||
@ -13,6 +13,8 @@ export interface ManualEntryPayload {
|
|||||||
buy_time?: string | null;
|
buy_time?: string | null;
|
||||||
sell_time?: string | null;
|
sell_time?: string | null;
|
||||||
quantity?: number | null;
|
quantity?: number | null;
|
||||||
|
amount_usd?: number | null;
|
||||||
|
sizing_mode?: string | null;
|
||||||
filled_quantity?: number | null;
|
filled_quantity?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@ -7,7 +7,9 @@ describe('SimpleView helpers', () => {
|
|||||||
draft: {
|
draft: {
|
||||||
symbol: 'aapl',
|
symbol: 'aapl',
|
||||||
side: 'buy',
|
side: 'buy',
|
||||||
|
sizingMode: 'quantity',
|
||||||
quantity: '5',
|
quantity: '5',
|
||||||
|
amountUsd: '',
|
||||||
currentMarketPrice: '210.25',
|
currentMarketPrice: '210.25',
|
||||||
dropMode: 'dollar',
|
dropMode: 'dollar',
|
||||||
dropValue: '12',
|
dropValue: '12',
|
||||||
@ -26,6 +28,8 @@ describe('SimpleView helpers', () => {
|
|||||||
is_real_trade: false,
|
is_real_trade: false,
|
||||||
label: 'Simple',
|
label: 'Simple',
|
||||||
quantity: 5,
|
quantity: 5,
|
||||||
|
amount_usd: null,
|
||||||
|
sizing_mode: 'quantity',
|
||||||
filled_quantity: null,
|
filled_quantity: null,
|
||||||
notes: 'Long-term compounder',
|
notes: 'Long-term compounder',
|
||||||
entry_price: null,
|
entry_price: null,
|
||||||
@ -50,7 +54,9 @@ describe('SimpleView helpers', () => {
|
|||||||
draft: {
|
draft: {
|
||||||
symbol: 'msft',
|
symbol: 'msft',
|
||||||
side: 'sell',
|
side: 'sell',
|
||||||
|
sizingMode: 'quantity',
|
||||||
quantity: '1',
|
quantity: '1',
|
||||||
|
amountUsd: '',
|
||||||
currentMarketPrice: '420.50',
|
currentMarketPrice: '420.50',
|
||||||
dropMode: 'percent',
|
dropMode: 'percent',
|
||||||
dropValue: '',
|
dropValue: '',
|
||||||
@ -76,6 +82,8 @@ describe('SimpleView helpers', () => {
|
|||||||
is_real_trade: false,
|
is_real_trade: false,
|
||||||
label: 'Simple',
|
label: 'Simple',
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
|
amount_usd: null,
|
||||||
|
sizing_mode: 'quantity',
|
||||||
filled_quantity: 10,
|
filled_quantity: 10,
|
||||||
notes: null,
|
notes: null,
|
||||||
entry_price: 380.25,
|
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', () => {
|
it('rejects sell setups without an existing holding', () => {
|
||||||
expect(() => buildSimpleSetupPayload({
|
expect(() => buildSimpleSetupPayload({
|
||||||
draft: {
|
draft: {
|
||||||
symbol: 'nvda',
|
symbol: 'nvda',
|
||||||
side: 'sell',
|
side: 'sell',
|
||||||
|
sizingMode: 'quantity',
|
||||||
quantity: '5',
|
quantity: '5',
|
||||||
|
amountUsd: '',
|
||||||
currentMarketPrice: '900',
|
currentMarketPrice: '900',
|
||||||
dropMode: 'percent',
|
dropMode: 'percent',
|
||||||
dropValue: '',
|
dropValue: '',
|
||||||
|
|||||||
@ -23,7 +23,9 @@ type TriggerMode = 'dollar' | 'percent';
|
|||||||
type SimpleSetupDraft = {
|
type SimpleSetupDraft = {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
side: SimpleSide;
|
side: SimpleSide;
|
||||||
|
sizingMode: 'quantity' | 'amount';
|
||||||
quantity: string;
|
quantity: string;
|
||||||
|
amountUsd: string;
|
||||||
currentMarketPrice: string;
|
currentMarketPrice: string;
|
||||||
dropMode: TriggerMode;
|
dropMode: TriggerMode;
|
||||||
dropValue: string;
|
dropValue: string;
|
||||||
@ -42,11 +44,27 @@ type SimpleHolding = {
|
|||||||
|
|
||||||
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
||||||
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
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 = {
|
const DEFAULT_DRAFT: SimpleSetupDraft = {
|
||||||
symbol: '',
|
symbol: '',
|
||||||
side: 'buy',
|
side: 'buy',
|
||||||
|
sizingMode: 'quantity',
|
||||||
quantity: '',
|
quantity: '',
|
||||||
|
amountUsd: '',
|
||||||
currentMarketPrice: '',
|
currentMarketPrice: '',
|
||||||
dropMode: 'percent',
|
dropMode: 'percent',
|
||||||
dropValue: '',
|
dropValue: '',
|
||||||
@ -127,9 +145,7 @@ export function buildSimpleSetupPayload(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const quantity = parsePositiveNumber(input.draft.quantity);
|
const quantity = parsePositiveNumber(input.draft.quantity);
|
||||||
if (!quantity) {
|
const amountUsd = parsePositiveNumber(input.draft.amountUsd);
|
||||||
throw new Error('Quantity is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const profitValue = parsePositiveNumber(input.draft.profitValue);
|
const profitValue = parsePositiveNumber(input.draft.profitValue);
|
||||||
if (!profitValue) {
|
if (!profitValue) {
|
||||||
@ -147,6 +163,16 @@ export function buildSimpleSetupPayload(input: {
|
|||||||
throw new Error('Drop trigger is required for buy setups');
|
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 {
|
return {
|
||||||
stock_instance_id: input.existingId,
|
stock_instance_id: input.existingId,
|
||||||
symbol,
|
symbol,
|
||||||
@ -155,7 +181,9 @@ export function buildSimpleSetupPayload(input: {
|
|||||||
is_crypto: false,
|
is_crypto: false,
|
||||||
is_real_trade: false,
|
is_real_trade: false,
|
||||||
label: 'Simple',
|
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,
|
filled_quantity: side === 'sell' ? holding!.size : null,
|
||||||
notes: input.draft.notes.trim() || null,
|
notes: input.draft.notes.trim() || null,
|
||||||
entry_price: side === 'sell' ? holding!.entryPrice : null,
|
entry_price: side === 'sell' ? holding!.entryPrice : null,
|
||||||
@ -194,9 +222,12 @@ function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null
|
|||||||
const profitText = draft.profitMode === 'dollar'
|
const profitText = draft.profitMode === 'dollar'
|
||||||
? `$${Number(draft.profitValue || 0).toFixed(2)} above purchase`
|
? `$${Number(draft.profitValue || 0).toFixed(2)} above purchase`
|
||||||
: `${draft.profitValue || '0'}% 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 [
|
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}.`,
|
profitTargetPrice ? `Exit target stays armed at ${profitTargetPrice.toFixed(4)} (${profitText}).` : `Exit target uses ${profitText}.`,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
}
|
}
|
||||||
@ -216,11 +247,38 @@ function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null
|
|||||||
return `Exit ${symbol} when price reaches ${profitTargetPrice.toFixed(4)} (${profitText}).`;
|
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 {
|
function buildDraftFromEntry(entry: ManualEntryPayload): SimpleSetupDraft {
|
||||||
return {
|
return {
|
||||||
symbol: String(entry.symbol || '').trim().toUpperCase(),
|
symbol: String(entry.symbol || '').trim().toUpperCase(),
|
||||||
side: normalizeSetupSide(entry.simple_side),
|
side: normalizeSetupSide(entry.simple_side),
|
||||||
|
sizingMode: String(entry.sizing_mode || '').trim().toLowerCase() === 'amount' ? 'amount' : 'quantity',
|
||||||
quantity: entry.quantity ? String(entry.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) : '',
|
currentMarketPrice: entry.reference_price ? Number(entry.reference_price).toFixed(4) : '',
|
||||||
dropMode: normalizeMode(entry.drop_trigger_mode, 'percent'),
|
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) : '',
|
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 {
|
function describeSavedSetup(entry: ManualEntryPayload): string {
|
||||||
const side = normalizeSetupSide(entry.simple_side);
|
const side = normalizeSetupSide(entry.simple_side);
|
||||||
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
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 profitMode = normalizeMode(entry.profit_target_mode, 'percent');
|
||||||
const referencePrice = Number(entry.reference_price || 0);
|
const referencePrice = Number(entry.reference_price || 0);
|
||||||
const entryPrice = Number(entry.entry_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') {
|
if (side === 'buy') {
|
||||||
const triggerPrice = computeBuyTriggerPrice(buildDraftFromEntry(entry));
|
const triggerPrice = computeBuyTriggerPrice(buildDraftFromEntry(entry));
|
||||||
@ -280,14 +347,14 @@ function describeSavedSetup(entry: ManualEntryPayload): string {
|
|||||||
const profitText = profitMode === 'dollar'
|
const profitText = profitMode === 'dollar'
|
||||||
? `$${profitValue.toFixed(2)} above purchase`
|
? `$${profitValue.toFixed(2)} above purchase`
|
||||||
: `${profitValue}% 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 profitTargetPrice = computeProfitTargetPrice(entryPrice || null, profitMode, String(profitValue || ''));
|
||||||
const profitText = profitMode === 'dollar'
|
const profitText = profitMode === 'dollar'
|
||||||
? `$${profitValue.toFixed(2)} above purchase`
|
? `$${profitValue.toFixed(2)} above purchase`
|
||||||
: `${profitValue}% 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() {
|
export function SimpleView() {
|
||||||
@ -322,11 +389,36 @@ export function SimpleView() {
|
|||||||
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0);
|
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0);
|
||||||
}, [botState.positions, simpleAutoProfile?.id]);
|
}, [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(
|
const matchingHolding = useMemo(
|
||||||
() => simpleHoldings.find((holding) => holding.symbol === normalizedSymbol) || null,
|
() => simpleHoldings.find((holding) => holding.symbol === normalizedSymbol) || null,
|
||||||
[simpleHoldings, normalizedSymbol],
|
[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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@ -518,8 +610,44 @@ export function SimpleView() {
|
|||||||
symbol: e.target.value.toUpperCase(),
|
symbol: e.target.value.toUpperCase(),
|
||||||
currentMarketPrice: '',
|
currentMarketPrice: '',
|
||||||
}))}
|
}))}
|
||||||
|
list={SIMPLE_SYMBOL_DATALIST_ID}
|
||||||
placeholder="AAPL"
|
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>
|
||||||
|
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
@ -561,18 +689,53 @@ export function SimpleView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="space-y-2">
|
{draft.side === 'buy' ? (
|
||||||
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
|
<>
|
||||||
{draft.side === 'buy' ? 'Planned quantity' : 'Holding size'}
|
<label className="space-y-2">
|
||||||
</span>
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Sizing method</span>
|
||||||
<Input
|
<Select
|
||||||
value={draft.side === 'sell' && matchingHolding ? String(matchingHolding.size) : draft.quantity}
|
value={draft.sizingMode}
|
||||||
onChange={(e) => updateDraft('quantity', e.target.value)}
|
onChange={(e) => updateDraft('sizingMode', e.target.value as 'quantity' | 'amount')}
|
||||||
readOnly={draft.side === 'sell' && !!matchingHolding}
|
>
|
||||||
className="read-only:bg-[var(--muted)]"
|
<option value="quantity">Quantity / fractional shares</option>
|
||||||
placeholder="10"
|
<option value="amount">USD amount</option>
|
||||||
/>
|
</Select>
|
||||||
</label>
|
</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">
|
<label className="space-y-2">
|
||||||
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Notes</span>
|
<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'
|
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
}`}>
|
}`}>
|
||||||
{matchingHolding
|
{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.'}
|
: 'No existing Simple holding found for this symbol. Sell setups only arm against a current Simple holding.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -677,7 +840,7 @@ export function SimpleView() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="block">
|
<CardHeader className="block">
|
||||||
<CardTitle className="uppercase">Saved setups</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -692,6 +855,7 @@ export function SimpleView() {
|
|||||||
const entryId = String(entry.stock_instance_id || '');
|
const entryId = String(entry.stock_instance_id || '');
|
||||||
const side = normalizeSetupSide(entry.simple_side);
|
const side = normalizeSetupSide(entry.simple_side);
|
||||||
const isEditing = editingSetupId === entryId;
|
const isEditing = editingSetupId === entryId;
|
||||||
|
const runtimeStatus = deriveRuntimeOrderStatus(entry, runtimeOrders, simpleHoldings);
|
||||||
return (
|
return (
|
||||||
<div key={entryId} className="rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5">
|
<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">
|
<div className="mb-3 flex items-start justify-between gap-4">
|
||||||
@ -753,6 +917,11 @@ export function SimpleView() {
|
|||||||
Trade linked
|
Trade linked
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{runtimeStatus ? (
|
||||||
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||||
|
{runtimeStatus}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user