//+------------------------------------------------------------------+ //| ExperimentRunner.mqh | //| Copyright 2026, MetaQuotes Ltd. | //| www.mql5.com | //+------------------------------------------------------------------+ #ifndef REQUEST_LATENCY_LAB_EXPERIMENT_RUNNER_MQH #define REQUEST_LATENCY_LAB_EXPERIMENT_RUNNER_MQH #include "..\..\Include\RequestLatencyLab\Models.mqh" #include "..\..\Include\RequestLatencyLab\Configuration.mqh" //+------------------------------------------------------------------+ //| Park-Miller PRNG (ТЗ §8.1): state=(16807*state) mod 2147483647. | //+------------------------------------------------------------------+ class CParkMiller { private: ulong m_state; //--- M = 2147483647 (модуль Park-Miller) public: CParkMiller() { m_state = 1; } void Seed(const ulong s) { m_state = (s % 2147483647ul == 0 ? 1 : s % 2147483647ul); } ulong Next(void) { m_state = (16807ul * m_state) % 2147483647ul; return(m_state); } //--- равномерный индекс [0,b): x=next-1; L=2147483646-(2147483646 mod b); //--- отклонить x>=L; индекс = x mod b. int NextUniform(const int b); }; struct ScheduleSlot { RequestPlan plan; ENUM_LAB_SLOT_STATUS status; string reason_code; ulong first_local_us; ulong last_local_us; int retry_count; // R4-B7: попытки без фактического send void Zero(void); }; class CExperimentRunner { private: LabSettings m_settings; CParkMiller m_prng_main; CParkMiller m_prng_warmup; ScheduleSlot m_slots[]; // основные слоты пары ветвей ScheduleSlot m_warmup[]; // прогревочные слоты int m_slot_count; int m_warmup_count; int m_next_warmup_idx; int m_next_main_idx; ulong m_next_sequence; // глобальный счётчик попыток сессии ulong m_n_main_dispatched; ulong m_n_warmup_dispatched; ulong m_n_cleanup_dispatched; ulong m_n_precheck_rejected; ulong m_n_dispatch_uncertain; //--- квоты E3 (по режиму) int m_regime_quota; // на условие int m_regime_used_a; int m_regime_used_b; public: CExperimentRunner(void); bool Init(const LabSettings &s, LabError &error); ulong NextSequence() { return(m_next_sequence++); } ulong MainDispatched() const { return(m_n_main_dispatched); } ulong WarmupDispatched() const { return(m_n_warmup_dispatched); } ulong CleanupDispatched() const { return(m_n_cleanup_dispatched); } ulong PrecheckRejected() const { return(m_n_precheck_rejected); } ulong DispatchUncertain() const { return(m_n_dispatch_uncertain); } //--- слоты: каждая ветвь получает ровно count_per_condition запросов, //--- стороны Buy/Sell чередуются (баланс +/-1); блоки сохраняются как //--- метки для schedule.csv (block_len = per_condition/10 при стандарте). //--- R5-S1: малые чётные пилоты (4/10/40) строятся корректно — каждый //--- слот получает валидный план, нулевые планы не создаются. bool BuildScheduleMain(LabError &error); bool BuildWarmup(LabError &error); bool NextWarmup(RequestPlan &plan); bool NextMain(RequestPlan &plan); //--- попытка отправить слот только при совпадении режима (E3) bool TryDispatchByRegime(const ENUM_LAB_MARKET_REGIME regime, RequestPlan &plan); void CountDispatched(const ENUM_LAB_ROLE role); //--- R4-B7: учёт фактической отправки слота (DISPATCHED) и E3-квоты условия. //--- Вызывается из контроллера сразу после входа в OrderSend/OrderSendAsync. void OnSlotDispatched(const ulong sequence); //--- B9: статус слота по sequence (фактическая отправка / отказ / wait) bool SetSlotStatusBySequence(const ulong sequence, const ENUM_LAB_SLOT_STATUS st, const string reason); //--- B9: зафиксировать фактическую отправку. R4-B7: только здесь слот //--- становится DISPATCHED; затем квота условия учитывается в OnSlotDispatched. void MarkDispatchedSlot(const ulong sequence) { SetSlotStatusBySequence(sequence, LAB_SLOT_DISPATCHED, ""); OnSlotDispatched(sequence); } //--- R4-B7: вернуть слот в очередь планировщика (не расходует слот/квоту). //--- Индекс возвращается назад, чтобы слот был выдан снова. bool RequeueSlotBySequence(const ulong sequence); //--- precheck-reject: ограниченное число повторов, затем терминальный //--- статус (слот не считается отправленным; серия станет INSUFFICIENT_DATA). void MarkRejectedSlot(const ulong sequence); //--- WAIT_DATA: слот возвращается в очередь (не расходуется). void MarkWaitSlot(const ulong sequence) { RequeueSlotBySequence(sequence); } //--- R4-B7: требуемое число фактических отправок (только MAIN-слоты) ulong RequiredDispatched(void) const; void MarkPrecheckRejected(void) { m_n_precheck_rejected++; } //--- R6-B4: прогревочные слоты имеют собственный lifecycle статусов bool RequeueWarmupBySequence(const ulong sequence); void MarkWarmupDispatched(const ulong sequence); void MarkWarmupRejected(const ulong sequence); void MarkCleanupDispatched(void) { m_n_cleanup_dispatched++; } void MarkDispatchUncertain(void) { m_n_dispatch_uncertain++; } int SlotCount() const { return(m_slot_count); } //--- R4-B7: E3-квоты заполнены фактическими отправками по обоим условиям bool E3QuotaComplete() const { return(m_regime_used_a >= m_regime_quota && m_regime_used_b >= m_regime_quota); } bool GetSlot(const int index, ScheduleSlot &out) const; bool MainComplete() const; bool WarmupComplete() const; //--- R7-B3: квота прогрева выполнена ТОЛЬКО фактическими отправками //--- (отдельно от «расписание исчерпано»; PRECHECK_REJECTED — НЕ успех). bool WarmupQuotaMet() const { return(m_n_warmup_dispatched >= WarmupRequired()); } //--- R7-B3: расписание прогрева исчерпано — нет слотов, ожидающих выдачи //--- (PLANNED/INTENT_SAVED). Это НЕ равно выполнению квоты: исчерпанное //--- расписание с недобором фактических отправок — неполный исход, //--- а не успешно завершённый прогрев (см. AppController::ReturnToSchedule). bool WarmupExhausted() const; ulong WarmupRequired() const { return((ulong)m_warmup_count); } // R6-B4 private: void SwapBlocks(RequestPlan &arr[], const int a, const int b, const int len); }; //+------------------------------------------------------------------+ //| Внешние определения методов. | //+------------------------------------------------------------------+ int CParkMiller::NextUniform(const int b) { if(b <= 1) return(0); const ulong L = 2147483646ul - ((2147483646ul) % ((ulong)b)); for(int guard = 0; guard < 1000; guard++) { const ulong x = Next() - 1; if(x >= L) continue; return(int)(x % ((ulong)b)); } return(int)(Next() % ((ulong)b)); } void ScheduleSlot::Zero(void) { plan.Zero(); status = LAB_SLOT_PLANNED; reason_code = ""; first_local_us = 0; last_local_us = 0; retry_count = 0; } CExperimentRunner::CExperimentRunner(void) { m_slot_count = 0; m_warmup_count = 0; m_next_warmup_idx = 0; m_next_main_idx = 0; m_next_sequence = 1; m_n_main_dispatched = 0; m_n_warmup_dispatched = 0; m_n_cleanup_dispatched = 0; m_n_precheck_rejected = 0; m_n_dispatch_uncertain = 0; m_regime_quota = 0; m_regime_used_a = 0; m_regime_used_b = 0; } bool CExperimentRunner::Init(const LabSettings &s, LabError &error) { error.Reset(); error.component = LAB_COMP_PLANNER; m_settings = s; m_prng_main.Seed(s.seed); m_prng_warmup.Seed(s.seed ^ 2654435769ul); const int per_condition = s.count_per_condition; const int total = per_condition * 2; const int warmup_total = s.warmup_per_condition * 2; //--- R5-S1: малые сбалансированные пилоты допустимы, но нечётные/нулевые //--- значения явно отвергаются ДО создания расписания. if(per_condition < 2 || (per_condition % 2) != 0) { error.code = 7; error.severity = LAB_SEV_BLOCKER; error.message = "count_per_condition must be even and >=2"; return(false); } if(s.warmup_per_condition < 0 || (s.warmup_per_condition % 2) != 0) { error.code = 8; error.severity = LAB_SEV_BLOCKER; error.message = "warmup_per_condition must be even (0 allowed)"; return(false); } if(ArrayResize(m_slots, total) != total || ArrayResize(m_warmup, warmup_total) != warmup_total) { error.code = 1; error.severity = LAB_SEV_BLOCKER; error.message = "cannot allocate schedule"; return(false); } m_slot_count = total; m_warmup_count = warmup_total; m_next_warmup_idx = 0; m_next_main_idx = 0; m_next_sequence = 1; //--- R11-B1: счётчики прогона независимы - новый Init не накапливает //--- отправки предыдущей сессии (иначе повторный запуск удваивал бы их). m_n_main_dispatched = 0; m_n_warmup_dispatched = 0; m_n_cleanup_dispatched = 0; m_n_precheck_rejected = 0; m_n_dispatch_uncertain = 0; m_regime_quota = s.count_per_condition; m_regime_used_a = 0; m_regime_used_b = 0; return(BuildScheduleMain(error) && BuildWarmup(error)); } bool CExperimentRunner::BuildScheduleMain(LabError &error) { const int per_condition = m_settings.count_per_condition; const int total = m_slot_count; RequestPlan raw[]; if(ArrayResize(raw, total) != total) { error.code = 2; return(false); } //--- R6-B4: сбалансированный блочный дизайн. Состав КАЖДОГО блока //--- формируется ДО перемешивания: block_len слотов ветви A (Buy/Sell //--- чередуются) + block_len слотов ветви B; затем Fisher-Yates только //--- внутри этого блока (глобальный shuffle разрушал 10A+10B). //--- block_len=per_condition/10 для штатных 100 (5Buy+5Sell на ветвь //--- блока); малые чётные пилоты получают один полный блок. const int block_len = (per_condition % 10 == 0 && per_condition >= 10 ? per_condition / 10 : per_condition); const int blocks = (block_len > 0 ? per_condition / block_len : 1); int idx = 0; for(int b = 0; b < blocks && idx < total; b++) { //--- при нечётном block_len знак сдвигается между блоками, чтобы //--- глобальный баланс ветви сохранялся (5Buy+5Sell на 10 слотов) const int parity = (block_len % 2 == 0 ? 0 : b); for(int c = 0; c < 2 && idx < total; c++) // условие A, B { const string cond = (c == 0 ? "A" : "B"); for(int k = 0; k < block_len && idx < total; k++) { const int s = ((k + parity) % 2 == 0 ? 0 : 1); // Buy/Sell чередуются RequestPlan p; p.Zero(); p.slot_id = (ulong)idx + 1; p.experiment_id = m_settings.experiment_id; p.series_id = (uint)m_settings.series_id; p.condition_id = cond; p.block_id = (uint)(b + 1); p.role = LAB_ROLE_MAIN; p.operation = (m_settings.experiment_id == LAB_EXP_E2 ? (cond == "A" ? LAB_OP_MARKET_OPEN : LAB_OP_PENDING_CREATE) : (m_settings.run_mode == LAB_RUN_PENDING_CREATE_DELETE ? LAB_OP_PENDING_CREATE : LAB_OP_MARKET_OPEN)); p.side = (s == 0 ? LAB_SIDE_BUY : LAB_SIDE_SELL); p.mode = (m_settings.experiment_id == LAB_EXP_E1 ? (cond == "A" ? LAB_MODE_SYNC : LAB_MODE_ASYNC) : LAB_MODE_ASYNC); if(m_settings.experiment_id == LAB_EXP_E5) p.logging_mode = (cond == "A" ? LAB_LOG_MINIMAL : LAB_LOG_VERBOSE); else p.logging_mode = LAB_LOG_MINIMAL; p.sequence = NextSequence(); p.symbol = m_settings.symbol; p.volume = 0.0; // заполняется контроллером p.distance_ticks = (double)m_settings.pending_distance_ticks; p.magic = m_settings.magic; raw[idx] = p; idx++; } } //--- R6-B4: перемешивание только внутри блока — состав блока //--- (10A+10B c 5Buy+5Sell на ветвь при стандарте) не разрушается. const int from = b * block_len * 2; const int to = (from + block_len * 2 < total ? from + block_len * 2 : total); for(int i = to - 1; i > from; i--) { const int j = from + m_prng_main.NextUniform(i - from + 1); if(j != i) { const RequestPlan tmp = raw[i]; raw[i] = raw[j]; raw[j] = tmp; } } } for(int i = 0; i < total; i++) { m_slots[i].Zero(); m_slots[i].plan = raw[i]; m_slots[i].plan.planned_order = (ulong)(i + 1); } return(true); } bool CExperimentRunner::BuildWarmup(LabError &error) { const int per_condition = m_settings.warmup_per_condition; const int total = m_warmup_count; int idx = 0; for(int c = 0; c < 2 && idx < total; c++) { const string cond = (c == 0 ? "A" : "B"); for(int k = 0; k < per_condition && idx < total; k++) { const int s = (k % 2 == 0 ? 0 : 1); RequestPlan p; p.Zero(); p.slot_id = (ulong)idx + 1; p.experiment_id = m_settings.experiment_id; p.series_id = (uint)m_settings.series_id; p.condition_id = cond; p.role = LAB_ROLE_WARMUP; p.operation = (m_settings.experiment_id == LAB_EXP_E2 ? (cond == "A" ? LAB_OP_MARKET_OPEN : LAB_OP_PENDING_CREATE) : (m_settings.run_mode == LAB_RUN_PENDING_CREATE_DELETE ? LAB_OP_PENDING_CREATE : LAB_OP_MARKET_OPEN)); p.side = (s == 0 ? LAB_SIDE_BUY : LAB_SIDE_SELL); p.mode = (m_settings.experiment_id == LAB_EXP_E1 ? (cond == "A" ? LAB_MODE_SYNC : LAB_MODE_ASYNC) : LAB_MODE_ASYNC); p.logging_mode = LAB_LOG_MINIMAL; p.sequence = NextSequence(); p.symbol = m_settings.symbol; p.distance_ticks = (double)m_settings.pending_distance_ticks; p.magic = m_settings.magic; m_warmup[idx].Zero(); m_warmup[idx].plan = p; idx++; } } m_warmup_count = idx; return(true); } bool CExperimentRunner::NextWarmup(RequestPlan &plan) { while(m_next_warmup_idx < m_warmup_count) { const int i = m_next_warmup_idx; m_next_warmup_idx++; if(m_warmup[i].status != LAB_SLOT_PLANNED) continue; m_warmup[i].status = LAB_SLOT_INTENT_SAVED; plan = m_warmup[i].plan; return(true); } return(false); } bool CExperimentRunner::NextMain(RequestPlan &plan) { //--- E3: режим окна должен совпадать с квотой условия; управляется //--- контроллером через TryDispatchByRegime; здесь — следующий слот. while(m_next_main_idx < m_slot_count) { const int i = m_next_main_idx; m_next_main_idx++; if(m_slots[i].status != LAB_SLOT_PLANNED) continue; //--- B9 (audit-3): DISPATCHED ставится только после фактического //--- входа в OrderSend*; здесь лишь INTENT_SAVED (намерение) m_slots[i].status = LAB_SLOT_INTENT_SAVED; m_slots[i].first_local_us = 0; plan = m_slots[i].plan; return(true); } return(false); } bool CExperimentRunner::TryDispatchByRegime(const ENUM_LAB_MARKET_REGIME regime, RequestPlan &plan) { if(regime != LAB_REGIME_QUIET && regime != LAB_REGIME_FAST) return(false); //--- R5-B6: выбирается ПЕРВАЯ подходящая возможность нужной ветви, //--- слот другой ветви впереди не блокирует отправку FAST/QUIET. int first_pending = -1; for(int i = 0; i < m_slot_count; i++) { if(m_slots[i].status != LAB_SLOT_PLANNED) continue; if(first_pending < 0) first_pending = i; //--- квота по условию уже использована const bool is_a = (m_slots[i].plan.condition_id == "A"); const int used = (is_a ? m_regime_used_a : m_regime_used_b); if(used >= m_regime_quota) continue; //--- A/B для E3 — это QUIET/FAST const ENUM_LAB_MARKET_REGIME need = (is_a ? LAB_REGIME_QUIET : LAB_REGIME_FAST); if(regime != need) continue; //--- R4-B7: квота условия растёт ТОЛЬКО при фактической отправке //--- (OnSlotDispatched); выдача intent квоту не расходует. m_slots[i].status = LAB_SLOT_INTENT_SAVED; plan = m_slots[i].plan; if(i + 1 > m_next_main_idx) m_next_main_idx = i + 1; return(true); } //--- подходящего слота нет: курсор остаётся на первом ожидающем if(first_pending >= 0 && first_pending < m_next_main_idx) m_next_main_idx = first_pending; return(false); } void CExperimentRunner::CountDispatched(const ENUM_LAB_ROLE role) { if(role == LAB_ROLE_MAIN) m_n_main_dispatched++; else if(role == LAB_ROLE_WARMUP) m_n_warmup_dispatched++; } void CExperimentRunner::OnSlotDispatched(const ulong sequence) { for(int i = 0; i < m_slot_count; i++) { if(m_slots[i].plan.sequence != sequence) continue; if(m_slots[i].status != LAB_SLOT_DISPATCHED) return; const bool is_a = (m_slots[i].plan.condition_id == "A"); if(is_a) m_regime_used_a++; else m_regime_used_b++; return; } } bool CExperimentRunner::SetSlotStatusBySequence(const ulong sequence, const ENUM_LAB_SLOT_STATUS st, const string reason) { //--- только MAIN-слоты имеют sequence в плане; cleanup/warmup не слота for(int i = 0; i < m_slot_count; i++) { if(m_slots[i].plan.sequence != sequence) continue; m_slots[i].status = st; if(StringLen(reason) > 0) m_slots[i].reason_code = reason; return(true); } return(false); } bool CExperimentRunner::RequeueSlotBySequence(const ulong sequence) { for(int i = 0; i < m_slot_count; i++) { if(m_slots[i].plan.sequence != sequence) continue; m_slots[i].status = LAB_SLOT_PLANNED; if(i < m_next_main_idx) m_next_main_idx = i; return(true); } //--- R6-B4: прогревочные слоты возвращаются так же (слот не расходуется) return(RequeueWarmupBySequence(sequence)); } void CExperimentRunner::MarkRejectedSlot(const ulong sequence) { for(int i = 0; i < m_slot_count; i++) { if(m_slots[i].plan.sequence != sequence) continue; m_slots[i].retry_count++; if(m_slots[i].retry_count >= 3) { m_slots[i].status = LAB_SLOT_PRECHECK_REJECTED; m_slots[i].reason_code = "precheck_rejected"; } else { m_slots[i].status = LAB_SLOT_PLANNED; if(i < m_next_main_idx) m_next_main_idx = i; } return; } //--- R6-B4: отказ прогревочного слота также ограничен повторами MarkWarmupRejected(sequence); } ulong CExperimentRunner::RequiredDispatched(void) const { ulong req = 0; for(int i = 0; i < m_slot_count; i++) if(m_slots[i].plan.role == LAB_ROLE_MAIN) req++; return(req); } bool CExperimentRunner::RequeueWarmupBySequence(const ulong sequence) { for(int j = 0; j < m_warmup_count; j++) { if(m_warmup[j].plan.sequence != sequence) continue; m_warmup[j].status = LAB_SLOT_PLANNED; if(j < m_next_warmup_idx) m_next_warmup_idx = j; return(true); } return(false); } void CExperimentRunner::MarkWarmupDispatched(const ulong sequence) { for(int j = 0; j < m_warmup_count; j++) { if(m_warmup[j].plan.sequence != sequence) continue; m_warmup[j].status = LAB_SLOT_DISPATCHED; return; } } void CExperimentRunner::MarkWarmupRejected(const ulong sequence) { for(int j = 0; j < m_warmup_count; j++) { if(m_warmup[j].plan.sequence != sequence) continue; m_warmup[j].retry_count++; if(m_warmup[j].retry_count >= 3) { m_warmup[j].status = LAB_SLOT_PRECHECK_REJECTED; m_warmup[j].reason_code = "precheck_rejected"; } else { m_warmup[j].status = LAB_SLOT_PLANNED; if(j < m_next_warmup_idx) m_next_warmup_idx = j; } return; } } bool CExperimentRunner::GetSlot(const int index, ScheduleSlot &out) const { if(index < 0 || index >= m_slot_count) return(false); out = m_slots[index]; return(true); } bool CExperimentRunner::MainComplete() const { //--- R6-B4: завершение НЕ по high-watermark индекса, а по фактическим //--- отправкам и терминальным состояниям слотов (иначе E3 объявляет //--- MainComplete при неотправленных слотах другой ветви режима). if(m_n_main_dispatched >= RequiredDispatched()) return(true); for(int i = 0; i < m_slot_count; i++) { const ENUM_LAB_SLOT_STATUS st = m_slots[i].status; if(st == LAB_SLOT_PLANNED || st == LAB_SLOT_INTENT_SAVED) return(false); } return(true); } bool CExperimentRunner::WarmupComplete() const { //--- R6-B4: прогрев считается выполненным только по фактическим //--- отправкам; терминально исчерпанные слоты не объявляют успех. if(m_n_warmup_dispatched >= WarmupRequired()) return(true); for(int i = 0; i < m_warmup_count; i++) { const ENUM_LAB_SLOT_STATUS st = m_warmup[i].status; if(st == LAB_SLOT_PLANNED || st == LAB_SLOT_INTENT_SAVED) return(false); } return(true); } bool CExperimentRunner::WarmupExhausted() const { if(m_n_warmup_dispatched >= WarmupRequired()) return(true); for(int i = 0; i < m_warmup_count; i++) { const ENUM_LAB_SLOT_STATUS st = m_warmup[i].status; if(st == LAB_SLOT_PLANNED || st == LAB_SLOT_INTENT_SAVED) return(false); } return(true); } void CExperimentRunner::SwapBlocks(RequestPlan &arr[], const int a, const int b, const int len) { for(int k = 0; k < len; k++) { const RequestPlan tmp = arr[a + k]; arr[a + k] = arr[b + k]; arr[b + k] = tmp; } } #endif // REQUEST_LATENCY_LAB_EXPERIMENT_RUNNER_MQH //+------------------------------------------------------------------+