feat(simple): save dip-buy and profit-exit setups
This commit is contained in:
parent
0bd46ab43b
commit
90e733b46c
@ -21,6 +21,80 @@ import { reconciliationWatchdogAutoResumeService } from './services/reconciliati
|
||||
import { listActiveTradeProfiles } from './services/profileRepository.js';
|
||||
import { listActiveTradingUsers } from './services/userRepository.js';
|
||||
import * as runtimeOrderRepository from './services/runtimeOrderRepository.js';
|
||||
import { listManualEntries, saveManualEntryForUser, type ManualEntryRecord } from './services/manualEntryRepository.js';
|
||||
|
||||
const SIMPLE_WORKFLOW_TYPE = 'simple';
|
||||
const SIMPLE_WORKER_INTERVAL_MS = 15_000;
|
||||
|
||||
const toPositiveNumber = (value: unknown): number | null => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||
};
|
||||
|
||||
const normalizeTriggerMode = (value: unknown): 'dollar' | 'percent' | null => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'dollar' || normalized === 'percent') return normalized;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isSimpleWorkflowEntry = (entry: ManualEntryRecord | null | undefined): entry is ManualEntryRecord => {
|
||||
return String(entry?.workflow_type || '').trim().toLowerCase() === SIMPLE_WORKFLOW_TYPE;
|
||||
};
|
||||
|
||||
const isSimpleBuyEntry = (entry: ManualEntryRecord): boolean => {
|
||||
return String(entry.simple_side || '').trim().toLowerCase() === 'buy';
|
||||
};
|
||||
|
||||
const isSimpleSellEntry = (entry: ManualEntryRecord): boolean => {
|
||||
return String(entry.simple_side || '').trim().toLowerCase() === 'sell';
|
||||
};
|
||||
|
||||
const isSimpleSubmittedStatus = (status?: string | null): boolean => {
|
||||
return String(status || '').trim().toLowerCase() === 'simple_entry_submitted';
|
||||
};
|
||||
|
||||
const isSimpleBoughtStatus = (status?: string | null): boolean => {
|
||||
return String(status || '').trim().toLowerCase() === 'simple_bought';
|
||||
};
|
||||
|
||||
const isSimpleSellArmedStatus = (status?: string | null): boolean => {
|
||||
return String(status || '').trim().toLowerCase() === 'simple_armed_sell';
|
||||
};
|
||||
|
||||
const isSimpleExitSubmittedStatus = (status?: string | null): boolean => {
|
||||
return String(status || '').trim().toLowerCase() === 'simple_exit_submitted';
|
||||
};
|
||||
|
||||
const shouldArmSimpleBuy = (entry: ManualEntryRecord): boolean => {
|
||||
return String(entry.status || '').trim().toLowerCase() === 'simple_armed_buy';
|
||||
};
|
||||
|
||||
const computeSimpleBuyTriggerPrice = (entry: ManualEntryRecord): number | null => {
|
||||
const referencePrice = toPositiveNumber(entry.reference_price);
|
||||
const threshold = toPositiveNumber(entry.drop_threshold_for_buy);
|
||||
const mode = normalizeTriggerMode(entry.drop_trigger_mode);
|
||||
if (!referencePrice || !threshold || !mode) return null;
|
||||
|
||||
if (mode === 'dollar') {
|
||||
const triggerPrice = referencePrice - threshold;
|
||||
return triggerPrice > 0 ? Number(triggerPrice.toFixed(4)) : null;
|
||||
}
|
||||
|
||||
const triggerPrice = referencePrice * (1 - (threshold / 100));
|
||||
return triggerPrice > 0 ? Number(triggerPrice.toFixed(4)) : null;
|
||||
};
|
||||
|
||||
const computeSimpleProfitTargetPrice = (entryPrice: number, entry: ManualEntryRecord): number | null => {
|
||||
const threshold = toPositiveNumber(entry.gain_threshold_for_sell);
|
||||
const mode = normalizeTriggerMode(entry.profit_target_mode);
|
||||
if (!(entryPrice > 0) || !threshold || !mode) return null;
|
||||
|
||||
if (mode === 'dollar') {
|
||||
return Number((entryPrice + threshold).toFixed(4));
|
||||
}
|
||||
|
||||
return Number((entryPrice * (1 + (threshold / 100))).toFixed(4));
|
||||
};
|
||||
|
||||
async function main() {
|
||||
logger.info(`Starting ${config.PRODUCT_ID} trading backend...`);
|
||||
@ -105,6 +179,153 @@ async function main() {
|
||||
}
|
||||
return Array.from(symbols);
|
||||
};
|
||||
const getSimpleWorkerContext = (entry: ManualEntryRecord): UserContext | null => {
|
||||
const requestedProfileId = String(entry.profile_id || '').trim();
|
||||
const requestedUserId = String(entry.user_id || '').trim();
|
||||
if (requestedProfileId) {
|
||||
const byProfile = userContexts.find((ctx) => ctx.profileId === requestedProfileId);
|
||||
if (byProfile) return byProfile;
|
||||
}
|
||||
if (requestedUserId) {
|
||||
const byUser = userContexts.find((ctx) => ctx.userId === requestedUserId);
|
||||
if (byUser) return byUser;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const resolveSimpleMarketPrice = (entry: ManualEntryRecord): number | null => {
|
||||
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
||||
if (!symbol) return null;
|
||||
const livePrice = Number(apiServer.getState().symbols?.[symbol]?.price || 0);
|
||||
return Number.isFinite(livePrice) && livePrice > 0 ? livePrice : null;
|
||||
};
|
||||
const bindSimpleBoughtPosition = async (entry: ManualEntryRecord, ctx: UserContext): Promise<boolean> => {
|
||||
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
||||
const activePosition = linkedTradeId
|
||||
? ctx.executor.getActivePosition(entry.symbol, linkedTradeId)
|
||||
: ctx.executor.getActivePosition(entry.symbol);
|
||||
if (!activePosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await saveManualEntryForUser(entry.user_id, {
|
||||
...entry,
|
||||
profile_id: entry.profile_id || ctx.profileId,
|
||||
linked_trade_id: activePosition.tradeId || entry.linked_trade_id,
|
||||
entry_price: activePosition.entryPrice,
|
||||
filled_quantity: activePosition.size,
|
||||
buy_time: entry.buy_time || new Date().toISOString(),
|
||||
status: 'simple_bought',
|
||||
active: true,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
let simpleWorkerRunning = false;
|
||||
const runSimpleWorker = async () => {
|
||||
if (simpleWorkerRunning) return;
|
||||
simpleWorkerRunning = true;
|
||||
try {
|
||||
const entries = (await listManualEntries()).filter((entry) => entry.active && isSimpleWorkflowEntry(entry));
|
||||
for (const entry of entries) {
|
||||
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
||||
if (!symbol) continue;
|
||||
|
||||
const ctx = getSimpleWorkerContext(entry);
|
||||
if (!ctx) continue;
|
||||
|
||||
const currentPrice = resolveSimpleMarketPrice(entry);
|
||||
if (!(currentPrice && currentPrice > 0)) continue;
|
||||
|
||||
if (shouldArmSimpleBuy(entry)) {
|
||||
const triggerPrice = computeSimpleBuyTriggerPrice(entry);
|
||||
const desiredQty = toPositiveNumber(entry.quantity);
|
||||
if (!triggerPrice || !desiredQty) continue;
|
||||
if (currentPrice > triggerPrice) continue;
|
||||
|
||||
const result = await ctx.manualTrader.executeRequest(
|
||||
symbol,
|
||||
'buy',
|
||||
desiredQty,
|
||||
'limit',
|
||||
triggerPrice,
|
||||
currentPrice,
|
||||
entry.user_id
|
||||
);
|
||||
if (!result.success) {
|
||||
logger.warn(`[SimpleWorker] Buy trigger failed for ${symbol}: ${result.error || 'unknown error'}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await saveManualEntryForUser(entry.user_id, {
|
||||
...entry,
|
||||
profile_id: entry.profile_id || ctx.profileId,
|
||||
filled_quantity: result.adjustedQty ?? entry.filled_quantity,
|
||||
buy_time: new Date().toISOString(),
|
||||
status: 'simple_entry_submitted',
|
||||
active: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSimpleSubmittedStatus(entry.status)) {
|
||||
await bindSimpleBoughtPosition(entry, ctx);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(isSimpleBoughtStatus(entry.status) || isSimpleSellArmedStatus(entry.status) || isSimpleExitSubmittedStatus(entry.status))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
||||
const activePosition = linkedTradeId
|
||||
? ctx.executor.getActivePosition(symbol, linkedTradeId)
|
||||
: ctx.executor.getActivePosition(symbol);
|
||||
|
||||
if (isSimpleExitSubmittedStatus(entry.status)) {
|
||||
if (!activePosition) {
|
||||
await saveManualEntryForUser(entry.user_id, {
|
||||
...entry,
|
||||
active: false,
|
||||
status: 'sellCompleted',
|
||||
sell_time: entry.sell_time || new Date().toISOString(),
|
||||
sell_price: currentPrice,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!activePosition) {
|
||||
if (isSimpleBoughtStatus(entry.status)) {
|
||||
await bindSimpleBoughtPosition(entry, ctx);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeEntryPrice = toPositiveNumber(entry.entry_price) || activePosition.entryPrice;
|
||||
const targetPrice = computeSimpleProfitTargetPrice(activeEntryPrice, entry);
|
||||
if (!targetPrice || currentPrice < targetPrice) continue;
|
||||
|
||||
const exitResult = await ctx.manualTrader.executeExit(symbol, currentPrice, 'Simple target hit', linkedTradeId || undefined);
|
||||
if (!exitResult.success) {
|
||||
logger.warn(`[SimpleWorker] Exit trigger failed for ${symbol}: ${exitResult.error || 'unknown error'}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await saveManualEntryForUser(entry.user_id, {
|
||||
...entry,
|
||||
profile_id: entry.profile_id || ctx.profileId,
|
||||
linked_trade_id: linkedTradeId || activePosition.tradeId,
|
||||
entry_price: activeEntryPrice,
|
||||
filled_quantity: entry.filled_quantity ?? activePosition.size,
|
||||
status: 'simple_exit_submitted',
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[SimpleWorker] Error during simple setup scan: ${error.message || error}`);
|
||||
} finally {
|
||||
simpleWorkerRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildOrderSyncHandler = (
|
||||
executor: TradeExecutor,
|
||||
@ -512,6 +733,14 @@ async function main() {
|
||||
|
||||
logger.info(`Monitoring ${collectMonitoredSymbols().join(', ')} via DATA=${config.DATA_PROVIDER} and EXECUTION=${config.EXECUTION_PROVIDER}`);
|
||||
|
||||
void runSimpleWorker();
|
||||
const simpleWorkerTimer = setInterval(() => {
|
||||
void runSimpleWorker();
|
||||
}, SIMPLE_WORKER_INTERVAL_MS);
|
||||
if (typeof simpleWorkerTimer.unref === 'function') {
|
||||
simpleWorkerTimer.unref();
|
||||
}
|
||||
|
||||
// --- State variables for periodic alerts (Global Signal State) ---
|
||||
const assetState = new Map<string, {
|
||||
price: number;
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
deleteTradeProfileForUser,
|
||||
ensureDefaultTradeProfileForUser,
|
||||
ensureSimpleAutoProfileForUser,
|
||||
getCurrentUserProfile,
|
||||
getTradeProfileForUser,
|
||||
listAllTradeProfiles,
|
||||
@ -2031,7 +2032,13 @@ export class ApiServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await saveManualEntryForUser(authUserId, req.body || {});
|
||||
const payload = { ...(req.body || {}) };
|
||||
if (String(payload.workflow_type || '').trim().toLowerCase() === 'simple') {
|
||||
const simpleProfile = await ensureSimpleAutoProfileForUser(authUserId, String(payload.symbol || ''));
|
||||
payload.profile_id = simpleProfile.id;
|
||||
payload.label = payload.label || 'Simple';
|
||||
}
|
||||
const entry = await saveManualEntryForUser(authUserId, payload);
|
||||
res.status(201).json({ entry });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: `Failed to save manual entry: ${error.message}` });
|
||||
@ -2046,10 +2053,16 @@ export class ApiServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await saveManualEntryForUser(authUserId, {
|
||||
const payload = {
|
||||
...(req.body || {}),
|
||||
stock_instance_id: String(req.params.id || '').trim()
|
||||
});
|
||||
} as Record<string, unknown>;
|
||||
if (String(payload.workflow_type || '').trim().toLowerCase() === 'simple') {
|
||||
const simpleProfile = await ensureSimpleAutoProfileForUser(authUserId, String(payload.symbol || ''));
|
||||
payload.profile_id = simpleProfile.id;
|
||||
payload.label = payload.label || 'Simple';
|
||||
}
|
||||
const entry = await saveManualEntryForUser(authUserId, payload);
|
||||
res.json({ entry });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: `Failed to update manual entry: ${error.message}` });
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ManualEntryRecord {
|
||||
symbol: string;
|
||||
active: boolean;
|
||||
user_id: string;
|
||||
profile_id?: string | null;
|
||||
buy_price?: number | null;
|
||||
sell_price?: number | null;
|
||||
buy_time?: string | null;
|
||||
@ -29,8 +30,14 @@ export interface ManualEntryRecord {
|
||||
is_real_trade: boolean;
|
||||
label?: string | null;
|
||||
entry_price?: number | null;
|
||||
reference_price?: number | null;
|
||||
gain_threshold_for_sell?: number | null;
|
||||
drop_threshold_for_buy?: number | null;
|
||||
workflow_type?: string | null;
|
||||
simple_side?: string | null;
|
||||
drop_trigger_mode?: string | null;
|
||||
profit_target_mode?: string | null;
|
||||
linked_trade_id?: string | null;
|
||||
}
|
||||
|
||||
type ManualEntryDocument = ManualEntryRecord & {
|
||||
@ -61,6 +68,7 @@ function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, exist
|
||||
symbol: String(input.symbol || existing?.symbol || '').trim(),
|
||||
active: Boolean(input.active ?? existing?.active ?? true),
|
||||
user_id: userId,
|
||||
profile_id: normalizeNullableString(input.profile_id ?? existing?.profile_id),
|
||||
buy_price: normalizeNullableNumber(input.buy_price ?? existing?.buy_price),
|
||||
sell_price: normalizeNullableNumber(input.sell_price ?? existing?.sell_price),
|
||||
buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time),
|
||||
@ -73,8 +81,14 @@ function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, exist
|
||||
is_real_trade: Boolean(input.is_real_trade ?? existing?.is_real_trade ?? false),
|
||||
label: normalizeNullableString(input.label ?? existing?.label),
|
||||
entry_price: normalizeNullableNumber(input.entry_price ?? existing?.entry_price),
|
||||
reference_price: normalizeNullableNumber(input.reference_price ?? existing?.reference_price),
|
||||
gain_threshold_for_sell: normalizeNullableNumber(input.gain_threshold_for_sell ?? existing?.gain_threshold_for_sell),
|
||||
drop_threshold_for_buy: normalizeNullableNumber(input.drop_threshold_for_buy ?? existing?.drop_threshold_for_buy),
|
||||
workflow_type: normalizeNullableString(input.workflow_type ?? existing?.workflow_type),
|
||||
simple_side: normalizeNullableString(input.simple_side ?? existing?.simple_side),
|
||||
drop_trigger_mode: normalizeNullableString(input.drop_trigger_mode ?? existing?.drop_trigger_mode),
|
||||
profit_target_mode: normalizeNullableString(input.profit_target_mode ?? existing?.profit_target_mode),
|
||||
linked_trade_id: normalizeNullableString(input.linked_trade_id ?? existing?.linked_trade_id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -34,6 +34,9 @@ export interface TradeProfileRecord {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
||||
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
||||
|
||||
export interface TradeProfileCapitalSummary {
|
||||
allocatedCapital: number;
|
||||
isActive: boolean;
|
||||
@ -117,6 +120,37 @@ function buildDefaultTradeProfile(userId: string): TradeProfileRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function parseProfileSymbols(symbolsRaw?: string | null): string[] {
|
||||
return String(symbolsRaw || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildSimpleAutoProfile(userId: string, symbol?: string): TradeProfileRecord {
|
||||
const normalizedSymbol = String(symbol || '').trim().toUpperCase();
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
id: randomUUID(),
|
||||
user_id: userId,
|
||||
name: SIMPLE_AUTO_PROFILE_NAME,
|
||||
allocated_capital: 1000,
|
||||
risk_per_trade_percent: 1,
|
||||
symbols: normalizedSymbol || 'AAPL',
|
||||
is_active: true,
|
||||
strategy_config: {
|
||||
mode: 'simple-auto',
|
||||
source: 'simple-tab',
|
||||
execution: {
|
||||
orderType: 'limit',
|
||||
entryMode: 'manual',
|
||||
},
|
||||
},
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTradingUserProfile(
|
||||
row: Partial<TradingUserProfile> | null | undefined,
|
||||
fallbackUserId?: string
|
||||
@ -491,6 +525,33 @@ export async function ensureDefaultTradeProfileForUser(userId: string): Promise<
|
||||
return [created];
|
||||
}
|
||||
|
||||
export async function ensureSimpleAutoProfileForUser(userId: string, symbol?: string): Promise<TradeProfileRecord> {
|
||||
const normalizedSymbol = String(symbol || '').trim().toUpperCase();
|
||||
const profiles = await listTradeProfilesForUser(userId);
|
||||
const existing = profiles.find((profile) => String(profile.name || '').trim().toLowerCase() === SIMPLE_AUTO_PROFILE_KEY);
|
||||
|
||||
if (!existing) {
|
||||
return saveTradeProfileForUser(buildSimpleAutoProfile(userId, normalizedSymbol), userId);
|
||||
}
|
||||
|
||||
const symbolSet = new Set(parseProfileSymbols(existing.symbols));
|
||||
if (normalizedSymbol) {
|
||||
symbolSet.add(normalizedSymbol);
|
||||
}
|
||||
const nextSymbols = Array.from(symbolSet).join(', ');
|
||||
const nextIsActive = true;
|
||||
const didChange = existing.symbols !== nextSymbols || !existing.is_active;
|
||||
if (!didChange) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return saveTradeProfileForUser({
|
||||
...existing,
|
||||
symbols: nextSymbols,
|
||||
is_active: nextIsActive,
|
||||
}, userId);
|
||||
}
|
||||
|
||||
export async function saveTradeProfileForUser(
|
||||
input: Partial<TradeProfileRecord>,
|
||||
userId: string
|
||||
|
||||
@ -7,6 +7,7 @@ export interface ManualEntryPayload {
|
||||
symbol: string;
|
||||
active: boolean;
|
||||
user_id?: string;
|
||||
profile_id?: string | null;
|
||||
buy_price?: number | null;
|
||||
sell_price?: number | null;
|
||||
buy_time?: string | null;
|
||||
@ -19,8 +20,14 @@ export interface ManualEntryPayload {
|
||||
is_real_trade: boolean;
|
||||
label?: string | null;
|
||||
entry_price?: number | null;
|
||||
reference_price?: number | null;
|
||||
gain_threshold_for_sell?: number | null;
|
||||
drop_threshold_for_buy?: number | null;
|
||||
workflow_type?: string | null;
|
||||
simple_side?: string | null;
|
||||
drop_trigger_mode?: string | null;
|
||||
profit_target_mode?: string | null;
|
||||
linked_trade_id?: string | null;
|
||||
}
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
|
||||
@ -1,96 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildSimpleAutoProfilePayload,
|
||||
buildSimpleTradePayload,
|
||||
computeProtectionPrices,
|
||||
SIMPLE_AUTO_PROFILE_NAME,
|
||||
} from './SimpleView';
|
||||
import { buildSimpleSetupPayload } from './SimpleView';
|
||||
|
||||
describe('SimpleView helpers', () => {
|
||||
it('computes buy-side protection prices from reference price', () => {
|
||||
expect(
|
||||
computeProtectionPrices({
|
||||
it('builds a buy dip setup payload with dollar drop and percent profit target', () => {
|
||||
const payload = buildSimpleSetupPayload({
|
||||
draft: {
|
||||
symbol: 'aapl',
|
||||
side: 'buy',
|
||||
referencePrice: 100,
|
||||
stopLossPercent: 5,
|
||||
takeProfitPercent: 8,
|
||||
}),
|
||||
).toEqual({ sl: 95, tp: 108 });
|
||||
});
|
||||
|
||||
it('computes sell-side protection prices from reference price', () => {
|
||||
expect(
|
||||
computeProtectionPrices({
|
||||
side: 'sell',
|
||||
referencePrice: 100,
|
||||
stopLossPercent: 5,
|
||||
takeProfitPercent: 8,
|
||||
}),
|
||||
).toEqual({ sl: 105, tp: 92 });
|
||||
});
|
||||
|
||||
it('builds a market buy trade payload from market price', () => {
|
||||
const payload = buildSimpleTradePayload({
|
||||
symbol: 'aapl',
|
||||
side: 'buy',
|
||||
orderType: 'market',
|
||||
quantity: '5',
|
||||
limitPrice: '',
|
||||
currentMarketPrice: '210.25',
|
||||
stopLossPercent: '4',
|
||||
takeProfitPercent: '10',
|
||||
isCrypto: false,
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
symbol: 'AAPL',
|
||||
side: 'buy',
|
||||
qty: 5,
|
||||
type: 'market',
|
||||
sl: 201.84,
|
||||
tp: 231.275,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a limit sell trade payload from limit price', () => {
|
||||
const payload = buildSimpleTradePayload({
|
||||
symbol: 'btc/usd',
|
||||
side: 'sell',
|
||||
orderType: 'limit',
|
||||
quantity: '0.25',
|
||||
limitPrice: '64000',
|
||||
currentMarketPrice: '65000',
|
||||
stopLossPercent: '3',
|
||||
takeProfitPercent: '6',
|
||||
isCrypto: true,
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
symbol: 'BTC/USD',
|
||||
side: 'sell',
|
||||
qty: 0.25,
|
||||
type: 'limit',
|
||||
price: 64000,
|
||||
sl: 65920,
|
||||
tp: 60160,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the dedicated simple auto profile payload', () => {
|
||||
expect(buildSimpleAutoProfilePayload('msft')).toEqual({
|
||||
name: SIMPLE_AUTO_PROFILE_NAME,
|
||||
allocated_capital: 1000,
|
||||
risk_per_trade_percent: 1,
|
||||
symbols: 'MSFT',
|
||||
is_active: true,
|
||||
strategy_config: {
|
||||
mode: 'simple-auto',
|
||||
source: 'simple-tab',
|
||||
execution: {
|
||||
orderType: 'market',
|
||||
entryMode: 'manual',
|
||||
},
|
||||
quantity: '5',
|
||||
currentMarketPrice: '210.25',
|
||||
dropMode: 'dollar',
|
||||
dropValue: '12',
|
||||
profitMode: 'percent',
|
||||
profitValue: '8',
|
||||
notes: 'Long-term compounder',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
stock_instance_id: undefined,
|
||||
symbol: 'AAPL',
|
||||
active: true,
|
||||
status: 'simple_armed_buy',
|
||||
is_crypto: false,
|
||||
is_real_trade: false,
|
||||
label: 'Simple',
|
||||
quantity: 5,
|
||||
filled_quantity: null,
|
||||
notes: 'Long-term compounder',
|
||||
entry_price: null,
|
||||
reference_price: 210.25,
|
||||
gain_threshold_for_sell: 8,
|
||||
drop_threshold_for_buy: 12,
|
||||
workflow_type: 'simple',
|
||||
simple_side: 'buy',
|
||||
drop_trigger_mode: 'dollar',
|
||||
profit_target_mode: 'percent',
|
||||
linked_trade_id: null,
|
||||
profile_id: null,
|
||||
buy_price: null,
|
||||
sell_price: null,
|
||||
buy_time: null,
|
||||
sell_time: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a sell setup payload from an existing holding', () => {
|
||||
const payload = buildSimpleSetupPayload({
|
||||
draft: {
|
||||
symbol: 'msft',
|
||||
side: 'sell',
|
||||
quantity: '1',
|
||||
currentMarketPrice: '420.50',
|
||||
dropMode: 'percent',
|
||||
dropValue: '',
|
||||
profitMode: 'dollar',
|
||||
profitValue: '15',
|
||||
notes: '',
|
||||
},
|
||||
holding: {
|
||||
symbol: 'MSFT',
|
||||
size: 10,
|
||||
entryPrice: 380.25,
|
||||
profileId: 'simple-profile',
|
||||
tradeId: 'TRD-123',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
stock_instance_id: undefined,
|
||||
symbol: 'MSFT',
|
||||
active: true,
|
||||
status: 'simple_armed_sell',
|
||||
is_crypto: false,
|
||||
is_real_trade: false,
|
||||
label: 'Simple',
|
||||
quantity: 10,
|
||||
filled_quantity: 10,
|
||||
notes: null,
|
||||
entry_price: 380.25,
|
||||
reference_price: 420.5,
|
||||
gain_threshold_for_sell: 15,
|
||||
drop_threshold_for_buy: null,
|
||||
workflow_type: 'simple',
|
||||
simple_side: 'sell',
|
||||
drop_trigger_mode: null,
|
||||
profit_target_mode: 'dollar',
|
||||
linked_trade_id: 'TRD-123',
|
||||
profile_id: 'simple-profile',
|
||||
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',
|
||||
quantity: '5',
|
||||
currentMarketPrice: '900',
|
||||
dropMode: 'percent',
|
||||
dropValue: '',
|
||||
profitMode: 'percent',
|
||||
profitValue: '6',
|
||||
notes: '',
|
||||
},
|
||||
})).toThrow('Sell setups require an existing Simple holding for this symbol.');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user