diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index 2972a2c..10f0805 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -37,6 +37,7 @@ export async function GET() { maxPositionSize: a.maxPositionSize, targetSameEquity: a.targetSameEquity, splitEnabled: a.splitEnabled, + minLegs: a.minLegs, combineCmeHolidays: a.combineCmeHolidays, withdrawalStages: a.withdrawalStages, })), diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 69c9b06..1143cd6 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -13,7 +13,7 @@ import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, - LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, + BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, type DayPlan, } from './trading-logic'; import { getSetting, setSetting } from './db'; @@ -354,11 +354,19 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) } // Trade one leg toward today's remaining target. Split-allowed big days carve a - // ≥$800 piece; everything else trades the full remaining amount in one go. The + // ≥$500 piece; everything else trades the full remaining amount in one go. The // carve fraction is seeded per (prefix, date, leg number) so like accounts in a // batch split their targets the same way, or close to it when remainders differ. + // An account type's min-legs setting forces at least that many carves + // on BIG days only — a day at or below the threshold still trades as + // one leg. Set per prefix, so like accounts still split alike. + const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0; const legAmount = item.plan.splitAllowed - ? pickLegAmount(item.plan.remainingToday, () => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate))) + ? pickLegAmount( + item.plan.remainingToday, + () => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)), + minLegs - legsToday(acc.id, tradeDate), + ) : item.plan.remainingToday; recordLeg(acc.id, tradeDate); @@ -567,8 +575,13 @@ export async function copyTrade() { const { client, acc, contract } = item; const cfg = getAccountConfig(acc.id).accountType!; + const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0; const legAmount = item.plan.splitAllowed - ? pickLegAmount(item.plan.remainingToday, () => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate))) + ? pickLegAmount( + item.plan.remainingToday, + () => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)), + minLegs - legsToday(acc.id, tradeDate), + ) : item.plan.remainingToday; recordLeg(acc.id, tradeDate); diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index d1a5f1c..a0ae08f 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -161,6 +161,8 @@ function validatePayload(raw: unknown): FirmCatalogPayload { ...type, splitEnabled: type.splitEnabled !== false, combineCmeHolidays: type.combineCmeHolidays === true, + // Lenient: a master from before the min-legs field means "no minimum". + minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0, } as unknown as CatalogAccountType; }); diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index f68895b..7574cfe 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -108,8 +108,12 @@ export interface CatalogAccountType { maxLoss: number; maxPositionSize: number; targetSameEquity: boolean; - /** False = big days trade as one unsplit leg instead of random ≥$800 pieces. */ + /** False = big days trade as one unsplit leg instead of random ≥$500 pieces. */ splitEnabled: boolean; + /** Big days (> the $1,700 split threshold) must carve at least this many + * legs (0 = however the carve falls). Smaller days always trade as one + * leg; clamped to what ≥$500 pieces make possible. */ + minLegs: number; /** True = CME holiday sessions count with the next full trading day, the way * firms that follow CME clearing report them (e.g. Apex: Sun/Mon/Tue around * a Monday holiday is ONE trading day, recorded on the Tuesday). */ diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 32d7d60..7104b46 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -53,7 +53,7 @@ export function resolveEffectiveProfitTarget( /** Split a day's target into multiple legs when it exceeds this. */ export const BIG_DAY_THRESHOLD = 1700; /** Every split leg must be at least this. */ -export const MIN_LEG = 800; +export const MIN_LEG = 500; /** After at least one trade, a residual at or below this counts as "day done". */ export const LEG_COMPLETION_EPSILON = 25; /** Safety valve: never fire more than this many legs per account per trade date. */ @@ -112,8 +112,22 @@ export function groupLegFraction(prefix: string, tradeDate: string, legIndex: nu * Pick the next leg to trade toward today's remaining target. * remaining ≤ BIG_DAY_THRESHOLD → trade it all in one final leg; otherwise carve a random * $5-rounded piece from [MIN_LEG, remaining − MIN_LEG] so the remainder also stays ≥ MIN_LEG. + * + * minLegsLeft (an account's min-legs setting minus the legs already fired + * today) forces a carve even below the big-day threshold and reserves MIN_LEG + * for each leg still owed after this one. It is clamped to what MIN_LEG-sized + * pieces make possible, so a small coast day is never forced into impossible + * splits. */ -export function pickLegAmount(remainingToday: number, rand: () => number = Math.random): number { +export function pickLegAmount(remainingToday: number, rand: () => number = Math.random, minLegsLeft = 0): number { + const owedAfter = Math.min(minLegsLeft, Math.floor(remainingToday / MIN_LEG)) - 1; + if (owedAfter > 0) { + const lo = MIN_LEG; + const hi = remainingToday - owedAfter * MIN_LEG; + if (hi <= lo) return lo; + const leg = Math.round((lo + rand() * (hi - lo)) / 5) * 5; + return Math.min(Math.max(leg, lo), hi); + } if (remainingToday <= BIG_DAY_THRESHOLD) return round2(remainingToday); const lo = MIN_LEG; const hi = remainingToday - MIN_LEG; diff --git a/types.ts b/types.ts index 5b203ee..d3cb294 100644 --- a/types.ts +++ b/types.ts @@ -11,7 +11,8 @@ export interface AccountConfig { maxLoss: number; // 0 = no limit; positive = account is "Dead" when loss exceeds this maxPositionSize: number; // 0 = no limit; positive = max contracts per trade targetSameEquity: boolean; // if true, reduce profitTarget by priorProfit after withdrawal - splitEnabled: boolean; // false = big days trade as one unsplit leg instead of random ≥$800 pieces + splitEnabled: boolean; // false = big days trade as one unsplit leg instead of random ≥$500 pieces + minLegs: number; // big days (> $1,700) must carve at least this many legs (0 = however the carve falls) combineCmeHolidays: boolean; // true = CME holiday sessions count with the next full trading day (Apex-style) withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[]; // per-stage fresh profit + consistency (empty = use base values) }