feat: weekend-aware scheduler with configurable cycle times

- auto_cycle.py: _scheduler_loop now distinguishes weekday (interval_hours)
  from weekend (weekend_cycle_times UTC slots or sleep until Monday);
  _parse_weekend_times() and _next_weekend_slot() helpers;
  get_status() exposes weekend_cycle_enabled + weekend_cycle_times
- cycle.py: CycleConfigRequest adds weekend_cycle_enabled + weekend_cycle_times;
  update_cycle_config validates HH:MM format and persists to config DB
- Config.tsx: weekend scheduling section with enable toggle + time picker
  (06:00/08:00/12:00/18:00/22:00/00:00 UTC presets, multi-select);
  weekendEnabled + weekendTimes state synced from cycle status

Default: enabled with 08:00 + 22:00 UTC (covers news scan + Globex open Sunday)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-21 20:00:06 +02:00
parent d4bc4e6624
commit 319ac35a26
3 changed files with 145 additions and 11 deletions

View File

@@ -46,6 +46,8 @@ class CycleConfigRequest(BaseModel):
min_score_threshold: Optional[int] = None
journal_retention_days: Optional[int] = None
maturity_threshold_pct: Optional[int] = None
weekend_cycle_enabled: Optional[bool] = None
weekend_cycle_times: Optional[str] = None # "HH:MM,HH:MM" in UTC
@router.post("/config")
@@ -77,6 +79,14 @@ def update_cycle_config(req: CycleConfigRequest):
if not (5 <= req.maturity_threshold_pct <= 75):
raise HTTPException(400, "maturity_threshold_pct must be between 5 and 75")
set_config("maturity_threshold_pct", str(req.maturity_threshold_pct))
if req.weekend_cycle_enabled is not None:
set_config("weekend_cycle_enabled", "true" if req.weekend_cycle_enabled else "false")
if req.weekend_cycle_times is not None:
# Validate format: comma-separated HH:MM values
import re
if not re.match(r'^(\d{2}:\d{2})(,\d{2}:\d{2})*$', req.weekend_cycle_times.strip()):
raise HTTPException(400, "weekend_cycle_times must be 'HH:MM' or 'HH:MM,HH:MM,...'")
set_config("weekend_cycle_times", req.weekend_cycle_times.strip())
# Restart scheduler to pick up changes
restart_scheduler()

View File

@@ -1654,25 +1654,83 @@ def _auto_synthesize_knowledge(ai_key: str) -> None:
# ── Scheduler ─────────────────────────────────────────────────────────────────
def _parse_weekend_times(times_str: str):
"""Parse 'HH:MM,HH:MM' into list of (hour, minute) tuples."""
result = []
for t in times_str.split(","):
t = t.strip()
try:
parts = t.split(":")
result.append((int(parts[0]), int(parts[1])))
except Exception:
pass
return result or [(8, 0), (22, 0)]
def _next_weekend_slot(now_utc: datetime, weekend_times):
"""Return (next_dt, wait_secs) for the next weekend cycle time (UTC)."""
from datetime import timedelta
today = now_utc.date()
candidates = []
for day_offset in range(3):
for h, m in weekend_times:
candidate = datetime(today.year, today.month, today.day, h, m) + timedelta(days=day_offset)
if candidate > now_utc + timedelta(minutes=5):
candidates.append(candidate)
if not candidates:
fallback = now_utc + timedelta(hours=12)
return fallback, 12 * 3600
next_slot = min(candidates)
return next_slot, max(60, (next_slot - now_utc).total_seconds())
def _scheduler_loop(stop_event: threading.Event):
"""Background loop that runs the cycle at the configured interval."""
import time
"""Background loop that runs the cycle at the configured interval.
Weekday: fires every auto_cycle_hours.
Weekend: fires only at weekend_cycle_times (UTC HH:MM list).
If weekend_cycle_enabled=false, sleeps until Monday.
"""
from services.database import get_config
from datetime import timedelta
while not stop_event.is_set():
try:
interval_hours = float(get_config("auto_cycle_hours") or "3")
weekend_enabled = (get_config("weekend_cycle_enabled") or "true").lower() == "true"
weekend_times_str = get_config("weekend_cycle_times") or "08:00,22:00"
except Exception:
interval_hours = 3.0
interval_hours, weekend_enabled, weekend_times_str = 3.0, True, "08:00,22:00"
_current_status["interval_hours"] = interval_hours
from datetime import timedelta
next_run = (datetime.utcnow() + timedelta(hours=interval_hours)).isoformat()
_current_status["next_run_at"] = next_run
logger.info(f"[Scheduler] Next cycle in {interval_hours}h (at {next_run[:16]} UTC)")
weekend_times = _parse_weekend_times(weekend_times_str)
now_utc = datetime.utcnow()
weekday = now_utc.weekday()
is_weekend = weekday >= 5
# Wait for the interval (or until stop is signalled)
stop_event.wait(timeout=interval_hours * 3600)
if is_weekend and not weekend_enabled:
# Sleep until Monday 00:05 UTC
days_to_monday = 7 - weekday # Sat→2, Sun→1
next_monday = datetime(now_utc.year, now_utc.month, now_utc.day, 0, 5) + timedelta(days=days_to_monday)
wait_secs = max(60, (next_monday - now_utc).total_seconds())
_current_status["next_run_at"] = next_monday.isoformat()
_current_status["interval_hours"] = interval_hours
logger.info(f"[Scheduler] Weekend — cycles désactivés. Reprise lundi {next_monday.strftime('%Y-%m-%d %H:%M')} UTC")
stop_event.wait(timeout=wait_secs)
continue # don't run a cycle, loop back
if is_weekend:
next_slot, wait_secs = _next_weekend_slot(now_utc, weekend_times)
_current_status["next_run_at"] = next_slot.isoformat()
_current_status["interval_hours"] = interval_hours
logger.info(f"[Scheduler] Weekend — prochain cycle à {next_slot.strftime('%H:%M')} UTC ({wait_secs/3600:.1f}h)")
else:
wait_secs = interval_hours * 3600
next_run = now_utc + timedelta(hours=interval_hours)
_current_status["next_run_at"] = next_run.isoformat()
_current_status["interval_hours"] = interval_hours
logger.info(f"[Scheduler] Prochain cycle dans {interval_hours}h ({next_run.strftime('%H:%M')} UTC)")
stop_event.wait(timeout=wait_secs)
if stop_event.is_set():
break
@@ -1778,6 +1836,12 @@ def get_status() -> Dict[str, Any]:
except Exception:
pass
try:
weekend_enabled = (get_config("weekend_cycle_enabled") or "true").lower() == "true"
weekend_cycle_times = get_config("weekend_cycle_times") or "08:00,22:00"
except Exception:
weekend_enabled, weekend_cycle_times = True, "08:00,22:00"
return {
**_current_status,
"enabled": enabled,
@@ -1787,6 +1851,8 @@ def get_status() -> Dict[str, Any]:
"min_score_threshold": min_score,
"journal_retention_days": retention_days,
"maturity_threshold_pct": maturity_pct,
"weekend_cycle_enabled": weekend_enabled,
"weekend_cycle_times": weekend_cycle_times,
"last_cycle": last,
"scheduler_alive": bool(_cycle_thread and _cycle_thread.is_alive()),
}

View File

@@ -432,6 +432,8 @@ export default function Config() {
const [minScore, setMinScore] = useState(0)
const [retentionDays, setRetentionDays] = useState(90)
const [maturityThreshold, setMaturityThreshold] = useState(35)
const [weekendEnabled, setWeekendEnabled] = useState(true)
const [weekendTimes, setWeekendTimes] = useState<string[]>(['08:00', '22:00'])
useEffect(() => {
if (cs) {
setCycleEnabled(cs.enabled ?? false)
@@ -441,6 +443,9 @@ export default function Config() {
setMinScore(cs.min_score_threshold ?? 0)
setRetentionDays(cs.journal_retention_days ?? 90)
setMaturityThreshold(cs.maturity_threshold_pct ?? 35)
setWeekendEnabled(cs.weekend_cycle_enabled ?? true)
const times = (cs.weekend_cycle_times || '08:00,22:00').split(',').map((t: string) => t.trim()).filter(Boolean)
setWeekendTimes(times)
}
}, [cs])
@@ -823,6 +828,49 @@ export default function Config() {
</div>
</div>
{/* Weekend scheduling */}
<div className="border border-slate-700/50 rounded-lg p-3 mb-4 bg-dark-700/30">
<div className="flex items-center justify-between mb-3">
<div>
<span className="text-xs font-semibold text-slate-300">Cycles weekend</span>
<p className="text-[10px] text-slate-500 mt-0.5">Sam/Dim Globex ouvre dim ~18h UTC. Heures en UTC.</p>
</div>
<button
onClick={() => setWeekendEnabled(!weekendEnabled)}
className={clsx('px-3 py-1.5 rounded border text-xs font-semibold transition-all', {
'bg-amber-600/20 border-amber-500/50 text-amber-300': weekendEnabled,
'bg-dark-700 border-slate-700 text-slate-500 hover:border-slate-500': !weekendEnabled,
})}>
{weekendEnabled ? '✓ Activé' : '○ Désactivé'}
</button>
</div>
{weekendEnabled && (
<div>
<label className="text-[10px] text-slate-500 mb-1.5 block">Horaires UTC (cliquer pour activer/désactiver)</label>
<div className="flex gap-1.5 flex-wrap">
{['06:00', '08:00', '12:00', '18:00', '22:00', '00:00'].map(t => {
const active = weekendTimes.includes(t)
return (
<button key={t}
onClick={() => setWeekendTimes(prev =>
active ? prev.filter(x => x !== t) : [...prev, t].sort()
)}
className={clsx('px-2.5 py-1 rounded text-xs font-mono transition-colors', {
'bg-amber-600 text-white': active,
'bg-dark-700 text-slate-500 hover:text-slate-300 border border-slate-700': !active,
})}>
{t}
</button>
)
})}
</div>
{weekendTimes.length === 0 && (
<p className="text-[10px] text-red-400 mt-1">Sélectionner au moins un horaire</p>
)}
</div>
)}
</div>
{cs?.last_cycle && (
<div className="card bg-dark-700/50 mb-2 text-xs space-y-2">
<div className="flex items-center gap-4 flex-wrap">
@@ -847,7 +895,17 @@ export default function Config() {
<div className="flex items-center gap-3">
<button
onClick={() => updateCycleConfig(
{ enabled: cycleEnabled, interval_hours: cycleHours, similarity_threshold: cycleSimilarity, min_ev_threshold: minEv, min_score_threshold: minScore, journal_retention_days: retentionDays, maturity_threshold_pct: maturityThreshold },
{
enabled: cycleEnabled,
interval_hours: cycleHours,
similarity_threshold: cycleSimilarity,
min_ev_threshold: minEv,
min_score_threshold: minScore,
journal_retention_days: retentionDays,
maturity_threshold_pct: maturityThreshold,
weekend_cycle_enabled: weekendEnabled,
weekend_cycle_times: weekendTimes.length > 0 ? weekendTimes.join(',') : '08:00,22:00',
},
{ onSuccess: () => { refetchCycle(); setSavedMsg('Auto-cycle configuré'); setTimeout(() => setSavedMsg(''), 2000) } }
)}
disabled={savingCycle}