From 7dba29c66fd239468cab2e1b19483ca39e1353af Mon Sep 17 00:00:00 2001 From: cryptooda Date: Sun, 9 Nov 2025 02:08:31 +0700 Subject: [PATCH] Add jobs --- .cursor/commands/build-solution.md | 243 +++ .cursor/commands/implement-api-changes.md | 299 +++ .cursor/commands/migration-local.md | 265 +++ .cursor/commands/migration-production.md | 95 + .cursor/commands/migration-sandbox.md | 76 + .cursor/commands/responsive.md | 626 ++++++ .../Workers processing/IMPLEMENTATION-PLAN.md | 84 +- src/Dockerfile-worker-api-dev | 29 +- .../Controllers/BacktestController.cs | 51 +- src/Managing.Api/Controllers/JobController.cs | 288 +++ .../Responses/BacktestJobStatusResponse.cs | 37 + .../Models/Responses/PaginatedJobsResponse.cs | 79 + .../Repositories/IBacktestJobRepository.cs | 134 ++ src/Managing.Application.Tests/BotsTests.cs | 10 +- .../Backtests/BacktestExecutor.cs | 280 +++ .../Backtests/BacktestJobService.cs | 254 +++ .../Backtests/Backtester.cs | 357 ++-- src/Managing.Application/Users/UserService.cs | 54 +- .../Workers/BacktestComputeWorker.cs | 422 ++++ src/Managing.Bootstrap/ApiBootstrap.cs | 2 + src/Managing.Bootstrap/ComputeBootstrap.cs | 170 ++ .../Managing.Bootstrap.csproj | 1 + src/Managing.Common/Enums.cs | 16 + src/Managing.Domain/Backtests/BacktestJob.cs | 157 ++ ...GeneticRequestIdToBacktestJobs.Designer.cs | 1705 +++++++++++++++++ ...obTypeAndGeneticRequestIdToBacktestJobs.cs | 82 + .../ManagingDbContextModelSnapshot.cs | 100 + .../PostgreSql/Entities/JobEntity.cs | 67 + .../PostgreSql/ManagingDbContext.cs | 40 + .../PostgreSql/PostgreSqlJobRepository.cs | 485 +++++ src/Managing.WebApp/package.json | 2 +- .../BottomMenuBar/BottomMenuBar.tsx | 16 + .../mollecules/BottomMenuBar/index.tsx | 2 + .../src/components/mollecules/Tabs/Tabs.tsx | 4 +- .../UserActionsButton/UserActionsButton.tsx | 84 + .../mollecules/UserActionsButton/index.tsx | 3 + .../src/components/mollecules/index.tsx | 3 + .../organism/Backtest/backtestTable.tsx | 45 +- .../src/generated/ManagingApi.ts | 261 +++ .../src/generated/ManagingApiTypes.ts | 77 + .../src/pages/adminPage/admin.tsx | 6 + .../src/pages/adminPage/jobs/jobsSettings.tsx | 510 +++++ .../src/pages/adminPage/jobs/jobsTable.tsx | 313 +++ .../src/pages/authPage/auth.tsx | 70 + .../pages/backtestPage/BundleRequestModal.tsx | 195 ++ .../pages/backtestPage/backtestScanner.tsx | 121 +- .../backtestPage/bundleRequestsTable.tsx | 66 +- .../pages/settingsPage/UserInfoSettings.tsx | 140 +- .../src/pages/settingsPage/settings.tsx | 6 - src/Managing.Workers.Api/Dockerfile | 39 + .../Managing.Workers.Api.csproj | 26 + src/Managing.Workers.Api/Program.cs | 119 ++ src/Managing.Workers.Api/Worker.cs | 24 + .../appsettings.Development.json | 27 + src/Managing.Workers.Api/appsettings.json | 38 + src/Managing.Workers.Api/captain-definition | 5 + src/Managing.sln | 11 + 57 files changed, 8362 insertions(+), 359 deletions(-) create mode 100644 .cursor/commands/build-solution.md create mode 100644 .cursor/commands/implement-api-changes.md create mode 100644 .cursor/commands/migration-local.md create mode 100644 .cursor/commands/migration-production.md create mode 100644 .cursor/commands/migration-sandbox.md create mode 100644 .cursor/commands/responsive.md create mode 100644 src/Managing.Api/Controllers/JobController.cs create mode 100644 src/Managing.Api/Models/Responses/BacktestJobStatusResponse.cs create mode 100644 src/Managing.Api/Models/Responses/PaginatedJobsResponse.cs create mode 100644 src/Managing.Application.Abstractions/Repositories/IBacktestJobRepository.cs create mode 100644 src/Managing.Application/Backtests/BacktestExecutor.cs create mode 100644 src/Managing.Application/Backtests/BacktestJobService.cs create mode 100644 src/Managing.Application/Workers/BacktestComputeWorker.cs create mode 100644 src/Managing.Bootstrap/ComputeBootstrap.cs create mode 100644 src/Managing.Domain/Backtests/BacktestJob.cs create mode 100644 src/Managing.Infrastructure.Database/Migrations/20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs.Designer.cs create mode 100644 src/Managing.Infrastructure.Database/Migrations/20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs.cs create mode 100644 src/Managing.Infrastructure.Database/PostgreSql/Entities/JobEntity.cs create mode 100644 src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlJobRepository.cs create mode 100644 src/Managing.WebApp/src/components/mollecules/BottomMenuBar/BottomMenuBar.tsx create mode 100644 src/Managing.WebApp/src/components/mollecules/BottomMenuBar/index.tsx create mode 100644 src/Managing.WebApp/src/components/mollecules/UserActionsButton/UserActionsButton.tsx create mode 100644 src/Managing.WebApp/src/components/mollecules/UserActionsButton/index.tsx create mode 100644 src/Managing.WebApp/src/pages/adminPage/jobs/jobsSettings.tsx create mode 100644 src/Managing.WebApp/src/pages/adminPage/jobs/jobsTable.tsx create mode 100644 src/Managing.Workers.Api/Dockerfile create mode 100644 src/Managing.Workers.Api/Managing.Workers.Api.csproj create mode 100644 src/Managing.Workers.Api/Program.cs create mode 100644 src/Managing.Workers.Api/Worker.cs create mode 100644 src/Managing.Workers.Api/appsettings.Development.json create mode 100644 src/Managing.Workers.Api/appsettings.json create mode 100644 src/Managing.Workers.Api/captain-definition diff --git a/.cursor/commands/build-solution.md b/.cursor/commands/build-solution.md new file mode 100644 index 00000000..16c083ee --- /dev/null +++ b/.cursor/commands/build-solution.md @@ -0,0 +1,243 @@ +# build-solution + +## When to Use + +Use this command when you want to: +- Build the entire .NET solution +- Fix compilation errors automatically +- Verify the solution builds successfully +- Check for and resolve build warnings + +## Prerequisites + +- .NET SDK installed (`dotnet --version`) +- Solution file exists: `src/Managing.sln` +- All project files are present and valid + +## Execution Steps + +### Step 1: Verify Solution File Exists + +Check that the solution file exists: + +Run: `test -f src/Managing.sln` + +**If solution file doesn't exist:** +- Error: "❌ Solution file not found at src/Managing.sln" +- **STOP**: Cannot proceed without solution file + +### Step 2: Restore NuGet Packages + +Restore packages before building: + +Run: `dotnet restore src/Managing.sln` + +**If restore succeeds:** +- Continue to Step 3 + +**If restore fails:** +- Show restore errors +- Common issues: + - Network connectivity issues + - NuGet feed authentication + - Package version conflicts +- **Try to fix:** + - Check network connectivity + - Verify NuGet.config exists and is valid + - Clear NuGet cache: `dotnet nuget locals all --clear` + - Retry restore +- **If restore still fails:** + - Show detailed error messages + - **STOP**: Cannot build without restored packages + +### Step 3: Build Solution + +Build the solution: + +Run: `dotnet build src/Managing.sln --no-restore` + +**If build succeeds with no errors:** +- Show: "✅ Build successful!" +- Show summary of warnings (if any) +- **SUCCESS**: Build completed + +**If build fails with errors:** +- Continue to Step 4 to fix errors + +**If build succeeds with warnings only:** +- Show warnings summary +- Ask user if they want to fix warnings +- If yes: Continue to Step 5 +- If no: **SUCCESS**: Build completed with warnings + +### Step 4: Fix Compilation Errors + +Analyze build errors and fix them automatically: + +**Common error types:** + +1. **Project reference errors:** + - Error: "project was not found" + - **Fix**: Check project file paths in .csproj files + - Verify project file names match references + - Update incorrect project references + +2. **Missing using statements:** + - Error: "The type or namespace name 'X' could not be found" + - **Fix**: Add missing `using` statements + - Check namespace matches + +3. **Type mismatches:** + - Error: "Cannot implicitly convert type 'X' to 'Y'" + - **Fix**: Add explicit casts or fix type definitions + - Check nullable reference types + +4. **Missing method/property:** + - Error: "'X' does not contain a definition for 'Y'" + - **Fix**: Check if method/property exists + - Verify spelling and accessibility + +5. **Nullable reference warnings (CS8625, CS8618):** + - **Fix**: Add `?` to nullable types or initialize properties + - Use null-forgiving operator `!` if appropriate + - Add null checks where needed + +6. **Package version conflicts:** + - Warning: "Detected package version outside of dependency constraint" + - **Fix**: Update package versions in .csproj files + - Align package versions across projects + +**For each error:** +- Identify the error type and location +- Read the file containing the error +- Fix the error following .NET best practices +- Re-run build to verify fix +- Continue until all errors are resolved + +**If errors cannot be fixed automatically:** +- Show detailed error messages +- Explain what needs to be fixed manually +- **STOP**: User intervention required + +### Step 5: Fix Warnings (Optional) + +If user wants to fix warnings: + +**Common warning types:** + +1. **Nullable reference warnings (CS8625, CS8618):** + - **Fix**: Add nullable annotations or initialize properties + - Use `string?` for nullable strings + - Initialize properties in constructors + +2. **Package version warnings (NU1608, NU1603, NU1701):** + - **Fix**: Update package versions to compatible versions + - Align MediatR versions across projects + - Update Microsoft.Extensions packages + +3. **Obsolete API warnings:** + - **Fix**: Replace with recommended alternatives + - Update to newer API versions + +**For each warning:** +- Identify warning type and location +- Fix following best practices +- Re-run build to verify fix + +**If warnings cannot be fixed:** +- Show warning summary +- Inform user warnings are acceptable +- **SUCCESS**: Build completed with acceptable warnings + +### Step 6: Verify Final Build + +Run final build to confirm all errors are fixed: + +Run: `dotnet build src/Managing.sln --no-restore` + +**If build succeeds:** +- Show: "✅ Build successful! All errors fixed." +- Show final warning count (if any) +- **SUCCESS**: Solution builds successfully + +**If errors remain:** +- Show remaining errors +- Return to Step 4 +- **STOP** if errors cannot be resolved after multiple attempts + +## Error Handling + +**If solution file not found:** +- Check path: `src/Managing.sln` +- Verify you're in the correct directory +- **STOP**: Cannot proceed without solution file + +**If restore fails:** +- Check network connectivity +- Verify NuGet.config exists +- Clear NuGet cache: `dotnet nuget locals all --clear` +- Check for authentication issues +- Retry restore + +**If project reference errors:** +- Check .csproj files for incorrect references +- Verify project file names match references +- Common issue: `Managing.Infrastructure.Database.csproj` vs `Managing.Infrastructure.Databases.csproj` +- Fix project references + +**If compilation errors persist:** +- Read error messages carefully +- Check file paths and line numbers +- Verify all dependencies are restored +- Check for circular references +- **STOP** if errors require manual intervention + +**If package version conflicts:** +- Update MediatR.Extensions.Microsoft.DependencyInjection to match MediatR version +- Update Microsoft.Extensions.Caching.Memory versions +- Align AspNetCore.HealthChecks.NpgSql versions +- Update packages in all affected projects + +## Example Execution + +**User input:** `/build-solution` + +**AI execution:** + +1. Verify solution: `test -f src/Managing.sln` → ✅ Exists +2. Restore packages: `dotnet restore src/Managing.sln` → ✅ Restored +3. Build solution: `dotnet build src/Managing.sln --no-restore` + - Found error: Project reference to `Managing.Infrastructure.Database.csproj` not found +4. Fix error: Update `Managing.Workers.Api.csproj` reference to `Managing.Infrastructure.Databases.csproj` +5. Re-build: `dotnet build src/Managing.sln --no-restore` → ✅ Build successful +6. Success: "✅ Build successful! All errors fixed." + +**If nullable warnings:** + +1-3. Same as above +4. Build succeeds with warnings: CS8625 nullable warnings +5. Fix warnings: Add `?` to nullable parameters, initialize properties +6. Re-build: `dotnet build src/Managing.sln --no-restore` → ✅ Build successful, warnings reduced +7. Success: "✅ Build successful! Warnings reduced." + +**If package conflicts:** + +1-3. Same as above +4. Build succeeds with warnings: NU1608 MediatR version conflicts +5. Fix warnings: Update MediatR.Extensions.Microsoft.DependencyInjection to 12.x +6. Re-build: `dotnet build src/Managing.sln --no-restore` → ✅ Build successful +7. Success: "✅ Build successful! Package conflicts resolved." + +## Important Notes + +- ✅ **Always restore first** - Ensures packages are available +- ✅ **Fix errors before warnings** - Errors block builds, warnings don't +- ✅ **Check project references** - Common source of build errors +- ✅ **Verify file names match** - Project file names must match references exactly +- ✅ **Nullable reference types** - Use `?` for nullable, initialize non-nullable properties +- ⚠️ **Package versions** - Keep versions aligned across projects +- ⚠️ **Warnings are acceptable** - Some warnings (like NU1701) may be acceptable +- 📦 **Solution location**: `src/Managing.sln` +- 🔧 **Build command**: `dotnet build src/Managing.sln` +- 🗄️ **Common fixes**: Project references, nullable types, package versions + diff --git a/.cursor/commands/implement-api-changes.md b/.cursor/commands/implement-api-changes.md new file mode 100644 index 00000000..af18b89e --- /dev/null +++ b/.cursor/commands/implement-api-changes.md @@ -0,0 +1,299 @@ +# implement-api-changes + +## When to Use + +Use this command when: +- `ManagingApi.ts` has been updated (regenerated from backend) +- New API endpoints or types have been added to the backend +- You need to implement frontend features that use the new API changes + +## Prerequisites + +- Git repository initialized +- `ManagingApi.ts` file exists at `src/Managing.WebApp/src/generated/ManagingApi.ts` +- Backend API is running and accessible +- Frontend project structure is intact + +## Execution Steps + +### Step 1: Check if ManagingApi.ts Has Changed + +Check git status for changes to ManagingApi.ts: + +Run: `git status --short src/Managing.WebApp/src/generated/ManagingApi.ts` + +**If file is modified:** +- Continue to Step 2 + +**If file is not modified:** +- Check if file exists: `test -f src/Managing.WebApp/src/generated/ManagingApi.ts` +- If missing: Error "ManagingApi.ts not found. Please regenerate it first." +- If exists but not modified: Inform "No changes detected in ManagingApi.ts. Nothing to implement." +- **STOP**: No changes to process + +### Step 2: Analyze Git Changes + +Get the diff to see what was added/changed: + +Run: `git diff HEAD src/Managing.WebApp/src/generated/ManagingApi.ts` + +**Analyze the diff to identify:** +- New client classes (e.g., `export class JobClient`) +- New methods in existing clients (e.g., `backtest_NewMethod()`) +- New interfaces/types (e.g., `export interface NewType`) +- New enums (e.g., `export enum NewEnum`) +- Modified existing types/interfaces + +**Extract key information:** +- Client class names (e.g., `JobClient`, `BacktestClient`) +- Method names and signatures (e.g., `job_GetJobs(page: number, pageSize: number)`) +- Request/Response types (e.g., `PaginatedJobsResponse`, `JobStatus`) +- HTTP methods (GET, POST, PUT, DELETE) + +### Step 3: Determine Frontend Implementation Needs + +Based on the changes, determine what needs to be implemented: + +**For new client classes:** +- Create or update hooks/services to use the new client +- Identify which pages/components should use the new API +- Determine data fetching patterns (useQuery, useMutation) + +**For new methods in existing clients:** +- Find existing components using that client +- Determine if new UI components are needed +- Check if existing components need updates + +**For new types/interfaces:** +- Identify where these types should be used +- Check if new form components are needed +- Determine if existing components need type updates + +**Common patterns to look for:** +- `*Client` classes → Create hooks in `src/Managing.WebApp/src/hooks/` +- `Get*` methods → Use `useQuery` for data fetching +- `Post*`, `Put*`, `Delete*` methods → Use `useMutation` for mutations +- `Paginated*` responses → Create paginated table components +- `*Request` types → Create form components + +### Step 4: Search Existing Frontend Code + +Search for related code to understand context: + +**For new client classes:** +- Search: `grep -r "Client" src/Managing.WebApp/src --include="*.tsx" --include="*.ts" | grep -i "similar"` +- Look for similar client usage patterns +- Find related pages/components + +**For new methods:** +- Search: `grep -r "ClientName" src/Managing.WebApp/src --include="*.tsx" --include="*.ts"` +- Find where the client is already used +- Check existing patterns + +**For new types:** +- Search: `grep -r "TypeName" src/Managing.WebApp/src --include="*.tsx" --include="*.ts"` +- Find if type is referenced anywhere +- Check related components + +### Step 5: Implement Frontend Features + +Based on analysis, implement the frontend code: + +#### 5.1: Create/Update API Hooks + +**For new client classes:** +- Create hook file: `src/Managing.WebApp/src/hooks/use[ClientName].tsx` +- Pattern: +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { [ClientName] } from '../generated/ManagingApi' +import { useApiUrlStore } from '../app/store/apiUrlStore' + +export const use[ClientName] = () => { + const { apiUrl } = useApiUrlStore() + const queryClient = useQueryClient() + const client = new [ClientName]({}, apiUrl) + + // Add useQuery hooks for GET methods + // Add useMutation hooks for POST/PUT/DELETE methods + + return { /* hooks */ } +} +``` + +**For new methods in existing clients:** +- Update existing hook file +- Add new useQuery/useMutation hooks following existing patterns + +#### 5.2: Create/Update Components + +**For GET methods (data fetching):** +- Create components that use `useQuery` with the new hook +- Follow existing component patterns (e.g., tables, lists, detail views) +- Use TypeScript types from ManagingApi.ts + +**For POST/PUT/DELETE methods (mutations):** +- Create form components or action buttons +- Use `useMutation` with proper error handling +- Show success/error toasts +- Invalidate relevant queries after mutations + +**For paginated responses:** +- Create paginated table components +- Use existing pagination patterns from the codebase +- Include sorting, filtering if supported + +#### 5.3: Create/Update Pages + +**If new major feature:** +- Create new page in `src/Managing.WebApp/src/pages/` +- Add routing if needed +- Follow existing page structure patterns + +**If extending existing feature:** +- Update existing page component +- Add new sections/components as needed + +#### 5.4: Update Types and Interfaces + +**If new types are needed:** +- Import types from ManagingApi.ts +- Use types in component props/interfaces +- Ensure type safety throughout + +### Step 6: Follow Frontend Patterns + +**Always follow these patterns:** + +1. **API Client Usage:** + - Get `apiUrl` from `useApiUrlStore()` + - Create client: `new ClientName({}, apiUrl)` + - Use in hooks, not directly in components + +2. **Data Fetching:** + - Use `useQuery` from `@tanstack/react-query` + - Set proper `queryKey` for caching + - Handle loading/error states + +3. **Mutations:** + - Use `useMutation` from `@tanstack/react-query` + - Invalidate related queries after success + - Show user-friendly error messages + +4. **Component Structure:** + - Use functional components with TypeScript + - Place static content at file end + - Use DaisyUI/Tailwind for styling + - Wrap in Suspense with fallback + +5. **Error Handling:** + - Catch errors in services/hooks + - Return user-friendly error messages + - Use error boundaries for unexpected errors + +### Step 7: Verify Implementation + +**Check for:** +- TypeScript compilation errors: `cd src/Managing.WebApp && npm run type-check` (if available) +- Import errors: All imports resolve correctly +- Type safety: All types from ManagingApi.ts are used correctly +- Pattern consistency: Follows existing codebase patterns + +**If errors found:** +- Fix TypeScript errors +- Fix import paths +- Ensure types match API definitions +- **STOP** if critical errors cannot be resolved + +### Step 8: Test Integration Points + +**Verify:** +- API client is instantiated correctly +- Query keys are unique and appropriate +- Mutations invalidate correct queries +- Error handling works properly +- Loading states are handled + +## Error Handling + +**If ManagingApi.ts doesn't exist:** +- Check path: `src/Managing.WebApp/src/generated/ManagingApi.ts` +- If missing: Inform user to regenerate using NSwag +- Suggest: Run backend API, then `cd src/Managing.Nswag && dotnet build` + +**If git diff is empty:** +- Check if file is staged: `git diff --cached` +- Check if file is untracked: `git status` +- If untracked: Use `git diff /dev/null src/Managing.WebApp/src/generated/ManagingApi.ts` + +**If cannot determine changes:** +- Show the diff output to user +- Ask user to clarify what needs to be implemented +- Proceed with manual implementation guidance + +**If frontend patterns unclear:** +- Search for similar implementations in codebase +- Follow closest matching pattern +- Ask user for clarification if needed + +**If TypeScript errors:** +- Check type definitions in ManagingApi.ts +- Ensure imports are correct +- Verify types match API response structure +- Fix type mismatches + +## Example Execution + +**User input:** `/implement-api-changes` + +**AI execution:** + +1. Check changes: `git status --short src/Managing.WebApp/src/generated/ManagingApi.ts` → Modified +2. Analyze diff: `git diff HEAD src/Managing.WebApp/src/generated/ManagingApi.ts` + - Found: New `JobClient` class + - Found: Methods: `job_GetJobs()`, `job_GetJobStatus()`, `job_CancelJob()` + - Found: Types: `PaginatedJobsResponse`, `BacktestJobStatusResponse`, `JobStatus` enum +3. Determine needs: + - Create `useJobClient` hook + - Create jobs list page/component + - Create job status component + - Add cancel job functionality +4. Search existing code: + - Found similar pattern: `useBacktestClient` hook + - Found similar page: `backtestPage` structure +5. Implement: + - Create `src/Managing.WebApp/src/hooks/useJobClient.tsx` + - Create `src/Managing.WebApp/src/pages/jobsPage/jobs.tsx` + - Create `src/Managing.WebApp/src/components/mollecules/JobStatusCard.tsx` + - Update routing if needed +6. Verify: Check TypeScript errors, imports, types +7. Success: "✅ Frontend implementation completed for Job API changes" + +**If new method in existing client:** + +1-2. Same as above +3. Found: New method `backtest_GetJobStatus(jobId: string)` in `BacktestClient` +4. Search: Found `BacktestClient` used in `backtestPage` +5. Implement: + - Update existing `useBacktestClient` hook + - Add job status display to backtest page + - Add polling for job status updates +6. Verify and complete + +## Important Notes + +- ✅ **Always use TanStack Query** - Never use useEffect for data fetching +- ✅ **Follow existing patterns** - Match codebase style and structure +- ✅ **Type safety first** - Use types from ManagingApi.ts +- ✅ **Error handling** - Services throw user-friendly errors +- ✅ **Query invalidation** - Invalidate related queries after mutations +- ✅ **Component structure** - Functional components, static content at end +- ✅ **Styling** - Use DaisyUI/Tailwind, mobile-first approach +- ⚠️ **Don't update ManagingApi.ts** - It's auto-generated +- ⚠️ **Check existing code** - Reuse components/hooks when possible +- ⚠️ **Test integration** - Verify API calls work correctly +- 📦 **Hook location**: `src/Managing.WebApp/src/hooks/` +- 🔧 **Component location**: `src/Managing.WebApp/src/components/` +- 📄 **Page location**: `src/Managing.WebApp/src/pages/` +- 🗄️ **API types**: Import from `src/Managing.WebApp/src/generated/ManagingApi.ts` + diff --git a/.cursor/commands/migration-local.md b/.cursor/commands/migration-local.md new file mode 100644 index 00000000..e04d78c4 --- /dev/null +++ b/.cursor/commands/migration-local.md @@ -0,0 +1,265 @@ +# migration-local + +## When to Use + +Use this command when you want to: +- Create a new EF Core migration based on model changes +- Apply the migration to your local PostgreSQL database +- Update your local database schema to match the current code + +## Prerequisites + +- .NET SDK installed (`dotnet --version`) +- PostgreSQL running locally +- Local database connection configured (default: `Host=localhost;Port=5432;Database=managing;Username=postgres;Password=postgres`) + +## Execution Steps + +### Step 1: Verify Database Project Structure + +Check that the database project exists: +- Database project: `src/Managing.Infrastructure.Database` +- Startup project: `src/Managing.Api` +- Migrations folder: `src/Managing.Infrastructure.Database/Migrations` + +### Step 2: Build the Solution + +Before creating migrations, ensure the solution builds successfully: + +Run: `dotnet build src/Managing.sln` + +**If build succeeds:** +- Continue to Step 3 + +**If build fails:** +- Show build errors +- Analyze errors: + - C# compilation errors + - Missing dependencies + - Configuration errors +- **Try to fix errors automatically:** + - Fix C# compilation errors + - Fix missing imports + - Fix configuration issues +- **If errors can be fixed:** + - Fix the errors + - Re-run build + - If build succeeds, continue to Step 3 + - If build still fails, show errors and ask user for help +- **If errors cannot be fixed automatically:** + - Show detailed error messages + - Explain what needs to be fixed + - **STOP**: Do not proceed until build succeeds + +### Step 3: Check for Pending Model Changes + +Check if there are any pending model changes that require a new migration: + +Run: `cd src/Managing.Infrastructure.Database && dotnet ef migrations add --dry-run --startup-project ../Managing.Api --name "CheckPendingChanges_$(date +%s)"` + +**If no pending changes detected:** +- Inform: "✅ No pending model changes detected. All migrations are up to date." +- Ask user: "Do you want to create a migration anyway? (y/n)" +- If yes: Continue to Step 4 +- If no: **STOP** - No migration needed + +**If pending changes detected:** +- Show what changes require migrations +- Continue to Step 4 + +### Step 4: Generate Migration Name + +Ask the user for a migration name, or generate one automatically: + +**Option 1: User provides name** +- Prompt: "Enter a migration name (e.g., 'AddBacktestJobsTable'):" +- Use the provided name + +**Option 2: Auto-generate name** +- Analyze model changes to suggest a descriptive name +- Format: `Add[Entity]Table`, `Update[Entity]Field`, `Remove[Entity]Field`, etc. +- Examples: + - `AddBacktestJobsTable` + - `AddJobTypeToBacktestJobs` + - `UpdateUserTableSchema` +- Ask user to confirm or modify the suggested name + +### Step 5: Create Migration + +Create the migration using EF Core: + +Run: `cd src/Managing.Infrastructure.Database && dotnet ef migrations add "" --startup-project ../Managing.Api` + +**If migration creation succeeds:** +- Show: "✅ Migration created successfully: " +- Show the migration file path +- Continue to Step 6 + +**If migration creation fails:** +- Show error details +- Common issues: + - Database connection issues + - Model configuration errors + - Missing design-time factory +- **Try to fix automatically:** + - Check connection string in `DesignTimeDbContextFactory.cs` + - Verify database is running + - Check model configurations +- **If errors can be fixed:** + - Fix the errors + - Re-run migration creation + - If succeeds, continue to Step 6 +- **If errors cannot be fixed:** + - Show detailed error messages + - Explain what needs to be fixed + - **STOP**: Do not proceed until migration is created + +### Step 6: Review Migration File (Optional) + +Show the user the generated migration file: + +Run: `cat src/Managing.Infrastructure.Database/Migrations/_.cs` + +Ask: "Review the migration file above. Does it look correct? (y/n)" + +**If user confirms:** +- Continue to Step 7 + +**If user wants to modify:** +- Allow user to edit the migration file +- After editing, ask to confirm again +- Continue to Step 7 + +### Step 7: Apply Migration to Local Database + +Apply the migration to the local database: + +Run: `cd src/Managing.Infrastructure.Database && dotnet ef database update --startup-project ../Managing.Api` + +**If update succeeds:** +- Show: "✅ Migration applied successfully to local database" +- Show: "Database schema updated: " +- Continue to Step 8 + +**If update fails:** +- Show error details +- Common issues: + - Database connection issues + - Migration conflicts + - Database schema conflicts + - Constraint violations +- **Try to fix automatically:** + - Check database connection + - Check for conflicting migrations + - Verify database state +- **If errors can be fixed:** + - Fix the errors + - Re-run database update + - If succeeds, continue to Step 8 +- **If errors cannot be fixed:** + - Show detailed error messages + - Explain what needs to be fixed + - Suggest: "You may need to manually fix the database or rollback the migration" + - **STOP**: Do not proceed until migration is applied + +### Step 8: Verify Migration Status + +Verify that the migration was applied successfully: + +Run: `cd src/Managing.Infrastructure.Database && dotnet ef migrations list --startup-project ../Managing.Api` + +**If migration is listed as applied:** +- Show: "✅ Migration status verified" +- Show the list of applied migrations +- Success message: "✅ Migration created and applied successfully!" + +**If migration is not listed or shows as pending:** +- Warn: "⚠️ Migration may not have been applied correctly" +- Show migration list +- Suggest checking the database manually + +## Error Handling + +### If build fails: +- **STOP immediately** - Do not create migrations for broken code +- Show build errors in detail +- Try to fix common errors automatically: + - C# compilation errors + - Import path errors + - Syntax errors + - Missing imports +- If errors can be fixed: + - Fix them automatically + - Re-run build + - If build succeeds, continue + - If build still fails, show errors and ask for help +- If errors cannot be fixed: + - Show detailed error messages + - Explain what needs to be fixed + - **STOP**: Do not proceed until build succeeds + +### If database connection fails: +- Check if PostgreSQL is running: `pg_isready` or `psql -h localhost -U postgres -c "SELECT 1"` +- Verify connection string in `DesignTimeDbContextFactory.cs` +- Check if database exists: `psql -h localhost -U postgres -lqt | cut -d \| -f 1 | grep -qw managing` +- If database doesn't exist, create it: `createdb -h localhost -U postgres managing` +- Retry migration creation + +### If migration conflicts: +- Check existing migrations: `cd src/Managing.Infrastructure.Database && dotnet ef migrations list --startup-project ../Managing.Api` +- If migration already exists with same name, suggest a different name +- If database schema conflicts, suggest reviewing the migration file + +### If database update fails: +- Check database state: `psql -h localhost -U postgres -d managing -c "\dt"` +- Check applied migrations: `psql -h localhost -U postgres -d managing -c "SELECT * FROM \"__EFMigrationsHistory\";"` +- If migration partially applied, may need to rollback or fix manually +- Suggest: "Review the error and fix the database state, or rollback the migration" + +## Example Execution + +**User input:** `/migration-local` + +**AI execution:** + +1. Verify structure: Check `src/Managing.Infrastructure.Database` exists ✅ +2. Build solution: `dotnet build src/Managing.sln` → ✅ Build successful! +3. Check pending changes: `dotnet ef migrations add --dry-run ...` → ⚠️ Pending changes detected +4. Generate name: Analyze changes → Suggest "AddBacktestJobsTable" +5. Confirm name: "Migration name: 'AddBacktestJobsTable'. Proceed? (y/n)" → User confirms +6. Create migration: `dotnet ef migrations add "AddBacktestJobsTable" ...` → ✅ Migration created +7. Review file: Show migration file → User confirms +8. Apply migration: `dotnet ef database update ...` → ✅ Migration applied +9. Verify status: `dotnet ef migrations list ...` → ✅ Migration verified +10. Success: "✅ Migration created and applied successfully!" + +**If build fails:** + +1-2. Same as above +3. Build: `dotnet build src/Managing.sln` → ❌ Build failed +4. Analyze errors: C# compilation error in `JobEntity.cs` +5. Fix errors: Update type definitions +6. Re-run build: `dotnet build src/Managing.sln` → ✅ Build successful! +7. Continue with migration creation + +**If database connection fails:** + +1-5. Same as above +6. Create migration: `dotnet ef migrations add ...` → ❌ Connection failed +7. Check database: `pg_isready` → Database not running +8. Inform user: "PostgreSQL is not running. Please start PostgreSQL and try again." +9. **STOP**: Wait for user to start database + +## Important Notes + +- ✅ **Always build before creating migrations** - ensures code compiles correctly +- ✅ **Review migration file before applying** - verify it matches your intent +- ✅ **Backup database before applying** - migrations can modify data +- ✅ **Use descriptive migration names** - helps track schema changes +- ⚠️ **Migration is applied to local database only** - use other tools for production +- ⚠️ **Ensure PostgreSQL is running** - connection will fail if database is down +- 📦 **Database project**: `src/Managing.Infrastructure.Database` +- 🔧 **Startup project**: `src/Managing.Api` +- 🗄️ **Local connection**: `Host=localhost;Port=5432;Database=managing;Username=postgres;Password=postgres` +- 📁 **Migrations folder**: `src/Managing.Infrastructure.Database/Migrations` + diff --git a/.cursor/commands/migration-production.md b/.cursor/commands/migration-production.md new file mode 100644 index 00000000..16780c3e --- /dev/null +++ b/.cursor/commands/migration-production.md @@ -0,0 +1,95 @@ +# migration-production + +## When to Use + +Run database migrations for ProductionLocal environment, apply pending EF Core migrations, create backups (MANDATORY), and verify connectivity. + +⚠️ **WARNING**: Production environment - exercise extreme caution. + +## Prerequisites + +- .NET SDK installed (`dotnet --version`) +- PostgreSQL accessible for ProductionLocal +- Connection string in `appsettings.ProductionLocal.json` +- `scripts/safe-migrate.sh` available and executable +- ⚠️ Production access permissions required + +## Execution Steps + +### Step 1: Verify Script Exists and is Executable + +Check: `test -f scripts/safe-migrate.sh` + +**If missing:** Error and **STOP** + +**If not executable:** `chmod +x scripts/safe-migrate.sh` + +### Step 2: Verify Environment Configuration + +Check: `test -f src/Managing.Api/appsettings.ProductionLocal.json` + +**If missing:** Check `appsettings.Production.json`, else **STOP** + +### Step 3: Production Safety Check + +⚠️ **CRITICAL**: Verify authorization, reviewed migrations, rollback plan, backup will be created. + +**Ask user:** "⚠️ You are about to run migrations on ProductionLocal. Are you sure? (yes/no)" + +**If confirmed:** Continue + +**If not confirmed:** **STOP** + +### Step 4: Run Migration Script + +Run: `./scripts/safe-migrate.sh ProductionLocal` + +**Script performs:** Build → Check connectivity → Create DB if needed → Prompt backup (always choose 'y') → Check pending changes → Generate script → Show for review → Wait confirmation → Apply → Verify + +**On success:** Show success, backup location, log location, remind to verify application functionality + +**On failure:** Show error output, diagnose (connectivity, connection string, server, permissions, data conflicts), provide guidance or **STOP** if unresolvable (suggest testing in non-prod first) + +## Error Handling + +**Script not found:** Check `ls -la scripts/safe-migrate.sh`, **STOP** if missing + +**Not executable:** `chmod +x scripts/safe-migrate.sh`, retry + +**Database connection fails:** Verify PostgreSQL running, check connection string in `appsettings.ProductionLocal.json`, verify network/firewall/credentials, ⚠️ **WARN** production connectivity issues require immediate attention + +**Build fails:** Show errors (C# compilation, missing dependencies, config errors), try auto-fix (compilation errors, imports, config), if fixed re-run else **STOP** with ⚠️ **WARN** never deploy broken code + +**Migration conflicts:** Review migration history, script handles idempotent migrations, schema conflicts may need manual intervention, ⚠️ **WARN** may require downtime + +**Backup fails:** **CRITICAL** - script warns, strongly recommend fixing before proceeding, **WARN** extreme risks if proceeding without backup + +**Migration partially applies:** ⚠️ **CRITICAL** dangerous state - check `__EFMigrationsHistory`, may need rollback, **STOP** until database state verified + +## Example Execution + +**Success flow:** +1. Verify script → ✅ +2. Check executable → ✅ +3. Verify config → ✅ +4. Safety check → User confirms +5. Run: `./scripts/safe-migrate.sh ProductionLocal` +6. Script: Build → Connect → Backup → Generate → Review → Confirm → Apply → Verify → ✅ +7. Show backup/log locations, remind to verify functionality + +**Connection fails:** Diagnose connection string/server, ⚠️ warn production issue, **STOP** + +**Build fails:** Show errors, try auto-fix, if fixed re-run else **STOP** with ⚠️ warn + +**User skips backup:** ⚠️ ⚠️ ⚠️ **CRITICAL WARNING** extremely risky, ask again, if confirmed proceed with caution else **STOP** + +## Important Notes + +- ⚠️ ⚠️ ⚠️ **PRODUCTION** - Extreme caution required +- ✅ Backup MANDATORY, review script before applying, verify functionality after +- ✅ Idempotent migrations - safe to run multiple times +- ⚠️ Environment: `ProductionLocal`, Config: `appsettings.ProductionLocal.json` +- ⚠️ Backups: `scripts/backups/ProductionLocal/`, Logs: `scripts/logs/` +- 📦 Keeps last 5 backups automatically +- 🚨 Have rollback plan, test in non-prod first, monitor after migration + diff --git a/.cursor/commands/migration-sandbox.md b/.cursor/commands/migration-sandbox.md new file mode 100644 index 00000000..5149bf5c --- /dev/null +++ b/.cursor/commands/migration-sandbox.md @@ -0,0 +1,76 @@ +# migration-sandbox + +## When to Use + +Run database migrations for SandboxLocal environment, apply pending EF Core migrations, create backups, and verify connectivity. + +## Prerequisites + +- .NET SDK installed (`dotnet --version`) +- PostgreSQL accessible for SandboxLocal +- Connection string in `appsettings.SandboxLocal.json` +- `scripts/safe-migrate.sh` available and executable + +## Execution Steps + +### Step 1: Verify Script Exists and is Executable + +Check: `test -f scripts/safe-migrate.sh` + +**If missing:** Error and **STOP** + +**If not executable:** `chmod +x scripts/safe-migrate.sh` + +### Step 2: Verify Environment Configuration + +Check: `test -f src/Managing.Api/appsettings.SandboxLocal.json` + +**If missing:** Check `appsettings.Sandbox.json`, else **STOP** + +### Step 3: Run Migration Script + +Run: `./scripts/safe-migrate.sh SandboxLocal` + +**Script performs:** Build projects → Check connectivity → Create DB if needed → Prompt backup → Check pending changes → Generate script → Apply migrations → Verify status + +**On success:** Show success message, backup location, log file location + +**On failure:** Show error output, diagnose (connectivity, connection string, server status, permissions), provide guidance or **STOP** if unresolvable + +## Error Handling + +**Script not found:** Check `ls -la scripts/safe-migrate.sh`, **STOP** if missing + +**Not executable:** `chmod +x scripts/safe-migrate.sh`, retry + +**Database connection fails:** Verify PostgreSQL running, check connection string in `appsettings.SandboxLocal.json`, verify network/firewall/credentials + +**Build fails:** Show errors (C# compilation, missing dependencies, config errors), try auto-fix (compilation errors, imports, config), if fixed re-run else **STOP** + +**Migration conflicts:** Review migration history, script handles idempotent migrations, schema conflicts may need manual intervention + +**Backup fails:** Script warns, recommend fixing before proceeding, warn if proceeding without backup + +## Example Execution + +**Success flow:** +1. Verify script → ✅ +2. Check executable → ✅ +3. Verify config → ✅ +4. Run: `./scripts/safe-migrate.sh SandboxLocal` +5. Script: Build → Connect → Backup → Generate → Apply → Verify → ✅ +6. Show backup/log locations + +**Connection fails:** Diagnose connection string/server, provide guidance, **STOP** + +**Build fails:** Show errors, try auto-fix, if fixed re-run else **STOP** + +## Important Notes + +- ✅ Backup recommended, script prompts for it +- ✅ Review migration script before applying +- ✅ Idempotent migrations - safe to run multiple times +- ⚠️ Environment: `SandboxLocal`, Config: `appsettings.SandboxLocal.json` +- ⚠️ Backups: `scripts/backups/SandboxLocal/`, Logs: `scripts/logs/` +- 📦 Keeps last 5 backups automatically + diff --git a/.cursor/commands/responsive.md b/.cursor/commands/responsive.md new file mode 100644 index 00000000..1d821c12 --- /dev/null +++ b/.cursor/commands/responsive.md @@ -0,0 +1,626 @@ +# responsive + +## When to Use + +Use this command when you want to: +- Implement responsive/mobile design using DaisyUI components +- Make existing components mobile-friendly with DaisyUI patterns +- Create beautiful, modern responsive layouts following DaisyUI documentation +- Optimize UI for different screen sizes using DaisyUI's responsive features + +## Prerequisites + +- Component or page file open or specified +- Tailwind CSS configured +- DaisyUI installed and configured +- Reference to DaisyUI documentation: https://daisyui.com/components/ +- Understanding of the component's current structure + +## Execution Steps + +### Step 1: Analyze Current Component + +Read the component file to understand its structure: + +**If file is open in editor:** +- Use the currently open file + +**If file path provided:** +- Read the file: `cat [file-path]` + +**Analyze:** +- Current layout structure (grid, flex, etc.) +- Existing responsive classes (if any) +- Component complexity and nesting +- Content that needs to be responsive (tables, forms, charts, cards) + +### Step 2: Identify Responsive Requirements + +Determine what needs to be responsive: + +**Common responsive patterns:** +- **Navigation**: Mobile hamburger menu, desktop horizontal nav +- **Tables**: Horizontal scroll on mobile, full table on desktop +- **Forms**: Stacked inputs on mobile, side-by-side on desktop +- **Cards/Grids**: Single column on mobile, multi-column on desktop +- **Charts**: Smaller on mobile, larger on desktop +- **Modals**: Full screen on mobile, centered on desktop +- **Text**: Smaller on mobile, larger on desktop +- **Spacing**: Tighter on mobile, more spacious on desktop + +**Identify:** +- Which elements need responsive behavior +- Breakpoints where layout should change +- Mobile vs desktop content differences + +### Step 3: Apply Mobile-First Responsive Design + +Implement responsive design using Tailwind's mobile-first approach: + +#### 3.1: Breakpoint Strategy + +**Tailwind breakpoints (mobile-first):** +- Base (default): Mobile (< 640px) +- `sm:` - Small devices (≥ 640px) +- `md:` - Medium devices (≥ 768px) +- `lg:` - Large devices (≥ 1024px) +- `xl:` - Extra large (≥ 1280px) +- `2xl:` - 2X Extra large (≥ 1536px) + +**Pattern:** Start with mobile styles, then add larger breakpoints: +```tsx +// Mobile first: base styles are for mobile +
+ // Mobile: full width, padding 4 + // md+: padding 6 + // lg+: padding 8 +
+``` + +#### 3.2: Layout Patterns + +**Grid Layouts:** +```tsx +// Single column mobile, multi-column desktop +
+ {/* Cards */} +
+ +// Responsive grid with auto-fit +
+``` + +**Flexbox Layouts:** +```tsx +// Stack on mobile, row on desktop +
+ {/* Items */} +
+ +// Center on mobile, space-between on desktop +
+``` + +**Container Patterns:** +```tsx +// Use layout utility class or custom container +
+ {/* Content with responsive margins */} +
+ +// Or custom responsive container +
+``` + +#### 3.3: Navigation Patterns (DaisyUI Navbar) + +**DaisyUI Navbar Pattern** (https://daisyui.com/components/navbar/): +```tsx +// DaisyUI navbar with responsive menu +
+ {/* Mobile menu button */} +
+ + Logo +
+ + {/* Desktop navigation */} +
+ +
+ + {/* Navbar end */} +
+ +
+
+ +// Mobile drawer/sidebar (DaisyUI Drawer pattern) +
+ +
+ +
    + {/* Mobile menu items */} +
+
+
+``` + +#### 3.4: Table Patterns (DaisyUI Table) + +**DaisyUI Table Patterns** (https://daisyui.com/components/table/): +```tsx +// Option 1: Horizontal scroll on mobile (recommended) +
+ + + + + + + + + {/* Table rows */} + +
Header 1Header 2
+
+ +// Option 2: Responsive table size (mobile: table-xs, desktop: table) +
+ + {/* Table content */} +
+
+ +// Option 3: Card layout on mobile, table on desktop +
+ {/* DaisyUI cards for mobile */} +
+
+ {/* Card content matching table data */} +
+
+
+
+ + {/* Table for desktop */} +
+
+``` + +#### 3.5: Form Patterns (DaisyUI Form) + +**DaisyUI Form Patterns** (https://daisyui.com/components/form/): +```tsx +// DaisyUI form-control with responsive grid +
+ {/* Stacked on mobile, side-by-side on desktop */} +
+
+ + +
+
+ + +
+
+ + {/* Full width field */} +
+ + +
+ + {/* Responsive button */} +
+ +
+
+``` + +#### 3.6: Typography Patterns + +**Responsive Text:** +```tsx +// Smaller on mobile, larger on desktop +

+ Title +

+ +

+ Content +

+``` + +#### 3.7: Spacing Patterns + +**Responsive Spacing:** +```tsx +// Tighter on mobile, more spacious on desktop +
+ {/* Content */} +
+ +// Responsive gaps +
+ {/* Items */} +
+``` + +#### 3.8: Modal/Dialog Patterns (DaisyUI Modal) + +**DaisyUI Modal Patterns** (https://daisyui.com/components/modal/): +```tsx +// Full screen on mobile, centered on desktop + +
+

Modal Title

+

Modal content

+
+ +
+
+
+ +
+
+ +// Responsive modal with different sizes + +
+ {/* Modal content */} +
+
+``` + +#### 3.9: Chart/Visualization Patterns + +**Responsive Charts:** +```tsx +// Responsive chart container +
+ +
+ +// Or use aspect ratio +
+ +
+``` + +### Step 4: Reference DaisyUI Documentation + +**Before implementing any component, check DaisyUI documentation:** +- Open or reference: https://daisyui.com/components/ +- Find the component you need (navbar, card, table, modal, etc.) +- Review the component's responsive examples and classes +- Use the exact DaisyUI classes and patterns from the docs + +**DaisyUI Documentation Structure:** +- Each component page shows examples +- Copy the exact class names and structure +- Adapt the examples to your use case with responsive breakpoints + +### Step 5: Apply DaisyUI Responsive Components + +Use DaisyUI components following official documentation: https://daisyui.com/components/ + +**DaisyUI Responsive Components (from docs):** + +1. **Navbar** (https://daisyui.com/components/navbar/): + - Use `navbar` with `navbar-start`, `navbar-center`, `navbar-end` + - Mobile hamburger: `btn btn-ghost lg:hidden` + - Desktop nav: `hidden lg:flex` + ```tsx +
+
+ +
+
+ {/* Desktop nav items */} +
+
+ ``` + +2. **Drawer** (https://daisyui.com/components/drawer/): + - Use `drawer` with `drawer-side` for mobile sidebar + - Toggle with `drawer-open` class + ```tsx +
+ +
+ +
    + {/* Sidebar content */} +
+
+
+ ``` + +3. **Card** (https://daisyui.com/components/card/): + - Use `card` with `card-body` for responsive cards + - Responsive grid: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3` + ```tsx +
+
+

Title

+

Content

+
+
+ ``` + +4. **Table** (https://daisyui.com/components/table/): + - Wrap in `overflow-x-auto` for mobile scroll + - Use `table-xs` for mobile, `table` for desktop + ```tsx +
+ + {/* Table content */} +
+
+ ``` + +5. **Modal** (https://daisyui.com/components/modal/): + - Use `modal` with `modal-box` for responsive modals + - Full screen mobile: `w-full max-w-none md:max-w-2xl` + ```tsx + +
+ {/* Modal content */} +
+
+ ``` + +6. **Form** (https://daisyui.com/components/form/): + - Use `form-control` with responsive grid + - Inputs: `input input-bordered w-full` + ```tsx +
+ + +
+ ``` + +7. **Bottom Navigation** (https://daisyui.com/components/bottom-navigation/): + - Use `btm-nav` for mobile bottom navigation + ```tsx +
+ + +
+ ``` + +8. **Tabs** (https://daisyui.com/components/tabs/): + - Use `tabs` with responsive layout + - Mobile: `tabs tabs-boxed`, Desktop: `tabs tabs-lifted` + ```tsx +
+ Tab 1 + Tab 2 +
+ ``` + +9. **Dropdown** (https://daisyui.com/components/dropdown/): + - Use `dropdown` with responsive positioning + ```tsx +
+ +
    + {/* Dropdown items */} +
+
+ ``` + +10. **Stats** (https://daisyui.com/components/stats/): + - Use `stats` with responsive grid + ```tsx +
+
...
+
+ ``` + +### Step 6: Implement Beautiful Mobile UX + +**Mobile UX Best Practices:** + +1. **Touch Targets:** + - Minimum 44x44px touch targets + - Adequate spacing between interactive elements + ```tsx + + ``` + +2. **Swipe Gestures:** + - Consider swipeable cards/carousels + - Use libraries like `react-swipeable` if needed + +3. **Bottom Navigation** (DaisyUI Bottom Nav - https://daisyui.com/components/bottom-navigation/): + - Use DaisyUI `btm-nav` for mobile bottom navigation + ```tsx +
+ + +
+ ``` + +4. **Sticky Headers:** + - Keep important actions accessible + ```tsx +
+ {/* Header content */} +
+ ``` + +5. **Loading States** (DaisyUI Loading - https://daisyui.com/components/loading/): + - Use DaisyUI loading spinners appropriately sized for mobile + ```tsx +
+ +
+ + // Or use loading text +
+ + Loading... +
+ ``` + +### Step 7: Test Responsive Breakpoints + +Verify the implementation works at different breakpoints: + +**Test breakpoints:** +- Mobile: 375px, 414px (iPhone sizes) +- Tablet: 768px, 1024px (iPad sizes) +- Desktop: 1280px, 1536px+ + +**Check:** +- Layout doesn't break at any breakpoint +- Text is readable at all sizes +- Interactive elements are easily tappable +- Content doesn't overflow horizontally +- Images scale appropriately + +### Step 8: Optimize Performance + +**Mobile Performance:** + +1. **Lazy Loading:** + - Lazy load images and heavy components + ```tsx + ... + ``` + +2. **Conditional Rendering:** + - Render mobile/desktop versions conditionally if needed + ```tsx + {isMobile ? : } + ``` + +3. **Reduce Animations on Mobile:** + - Consider `prefers-reduced-motion` + ```tsx +
+ ``` + +## Error Handling + +**If component file not found:** +- Check file path +- Verify file exists +- **STOP**: Cannot proceed without component + +**If Tailwind classes not working:** +- Verify Tailwind config includes the file +- Check if classes are in content paths +- Rebuild Tailwind: `npm run build` or check build process + +**If layout breaks at breakpoints:** +- Check for conflicting classes +- Verify breakpoint order (mobile-first) +- Test with browser dev tools +- Fix overflow issues with `overflow-x-hidden` on body + +**If DaisyUI components not responsive:** +- Check DaisyUI version: `npm list daisyui` +- Verify Tailwind config includes DaisyUI plugin +- Reference DaisyUI docs: https://daisyui.com/components/ +- Use DaisyUI's responsive utilities from documentation +- Ensure you're using the correct DaisyUI class names + +## Example Execution + +**User input:** `/responsive` (with component file open) + +**AI execution:** + +1. Analyze component: Read `UnifiedTradingModal.tsx` + - Found: Large form with multiple sections + - Found: Tables and cards + - Found: Some responsive classes already present +2. Identify requirements: + - Form inputs need stacking on mobile + - Tables need horizontal scroll + - Modal needs full-screen on mobile + - Cards need single column on mobile +3. Reference DaisyUI docs: + - Check https://daisyui.com/components/modal/ for modal patterns + - Check https://daisyui.com/components/form/ for form patterns + - Check https://daisyui.com/components/table/ for table patterns + - Check https://daisyui.com/components/card/ for card patterns +4. Implement responsive using DaisyUI: + - Update form: Use `form-control` with `grid grid-cols-1 md:grid-cols-2 gap-4` + - Update tables: Wrap in `overflow-x-auto` with `table table-zebra` + - Update modal: Use DaisyUI `modal` with `modal-box w-full max-w-none md:max-w-2xl` + - Update cards: Use DaisyUI `card` with `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3` +5. Apply mobile UX: + - Use DaisyUI buttons (already meet 44px touch target) + - Add responsive spacing: `p-4 md:p-6` + - Update typography: `text-sm md:text-base` +6. Test: Verify at 375px, 768px, 1024px breakpoints +7. Success: "✅ Component is now fully responsive using DaisyUI components!" + +**If table component:** + +1. Analyze: Read table component +2. Identify: Table needs mobile-friendly layout +3. Implement: + - Option 1: Horizontal scroll wrapper + - Option 2: Card layout for mobile, table for desktop +4. Choose best approach based on data complexity +5. Implement chosen pattern +6. Success: "✅ Table is now responsive with [chosen pattern]!" + +## Important Notes + +- ✅ **Mobile-first approach** - Base styles for mobile, then add larger breakpoints +- ✅ **Use Tailwind breakpoints** - sm: 640px, md: 768px, lg: 1024px, xl: 1280px, 2xl: 1536px +- ✅ **DaisyUI components** - Always use DaisyUI components from https://daisyui.com/components/ +- ✅ **Follow DaisyUI docs** - Reference official documentation for component usage +- ✅ **Touch targets** - Minimum 44x44px for mobile (DaisyUI buttons meet this) +- ✅ **No horizontal scroll** - Prevent horizontal overflow on mobile +- ✅ **Test all breakpoints** - Verify at 375px, 768px, 1024px, 1280px +- ✅ **Performance** - Lazy load images, optimize for mobile +- ⚠️ **Breakpoint order** - Always mobile-first: base → sm → md → lg → xl → 2xl +- ⚠️ **Content priority** - Show most important content first on mobile +- ⚠️ **Spacing** - Tighter on mobile, more spacious on desktop +- ⚠️ **DaisyUI classes** - Use DaisyUI utility classes (`btn`, `card`, `input`, etc.) +- 📱 **Mobile breakpoints**: < 640px (base), ≥ 640px (sm), ≥ 768px (md) +- 💻 **Desktop breakpoints**: ≥ 1024px (lg), ≥ 1280px (xl), ≥ 1536px (2xl) +- 🎨 **DaisyUI Components**: `navbar`, `drawer`, `card`, `table`, `modal`, `form`, `btm-nav`, `tabs`, `dropdown`, `stats` +- 📚 **DaisyUI Docs**: https://daisyui.com/components/ - Always reference for component patterns +- 🔧 **Layout utility**: Use `.layout` class or custom responsive containers + diff --git a/assets/documentation/Workers processing/IMPLEMENTATION-PLAN.md b/assets/documentation/Workers processing/IMPLEMENTATION-PLAN.md index 6032d79a..f351b305 100644 --- a/assets/documentation/Workers processing/IMPLEMENTATION-PLAN.md +++ b/assets/documentation/Workers processing/IMPLEMENTATION-PLAN.md @@ -11,61 +11,79 @@ ## Phase 2: Compute Worker Project -- [ ] Create `Managing.Compute` project (console app/worker service) -- [ ] Add project reference to shared projects (Application, Domain, Infrastructure) -- [ ] Configure DI container (NO Orleans) +- [ ] Refactor `Managing.Workers.Api` project (or rename to `Managing.Compute`) +- [ ] Remove Orleans dependencies completely +- [ ] Add project references to shared projects (Application, Domain, Infrastructure) +- [ ] Configure DI container with all required services (NO Orleans) - [ ] Create `BacktestComputeWorker` background service - [ ] Implement job polling logic (every 5 seconds) - [ ] Implement job claiming with PostgreSQL advisory locks - [ ] Implement semaphore-based concurrency control - [ ] Implement progress callback mechanism - [ ] Implement heartbeat mechanism (every 30 seconds) -- [ ] Add configuration: `MaxConcurrentBacktests`, `JobPollIntervalSeconds` +- [ ] Add configuration: `MaxConcurrentBacktests`, `JobPollIntervalSeconds`, `WorkerId` ## Phase 3: API Server Updates - [ ] Update `BacktestController` to create jobs instead of calling grains directly - [ ] Implement `CreateBundleBacktest` endpoint (returns immediately) -- [ ] Implement `GetBundleStatus` endpoint (polls database) +- [ ] Implement `GetJobStatus` endpoint (polls database for single job) +- [ ] Implement `GetBundleStatus` endpoint (polls database, aggregates job statuses) - [ ] Update `Backtester.cs` to generate `BacktestJob` entities from bundle variants -- [ ] Remove direct Orleans grain calls for backtests (keep for other operations) +- [ ] Remove all Orleans grain calls for backtests (direct replacement, no feature flags) +- [ ] Remove `IGrainFactory` dependency from `Backtester.cs` -## Phase 4: Shared Logic +## Phase 4: Shared Logic Extraction -- [ ] Extract backtest execution logic from `BacktestTradingBotGrain` to `Backtester.cs` -- [ ] Make backtest logic Orleans-agnostic (can run in worker or grain) -- [ ] Add progress callback support to `RunBacktestAsync` method -- [ ] Ensure candle loading works in both contexts +- [ ] Create `BacktestExecutor.cs` service (new file) +- [ ] Extract backtest execution logic from `BacktestTradingBotGrain` to `BacktestExecutor` +- [ ] Make backtest logic Orleans-agnostic (no grain dependencies) +- [ ] Add progress callback support to execution method +- [ ] Ensure candle loading works in compute worker context +- [ ] Handle credit debiting/refunding in executor +- [ ] Handle user context resolution in executor ## Phase 5: Monitoring & Health Checks -- [ ] Add health check endpoint to compute worker +- [ ] Add health check endpoint to compute worker (`/health` or `/healthz`) - [ ] Add metrics: pending jobs, running jobs, completed/failed counts -- [ ] Add stale job detection (reclaim jobs from dead workers) -- [ ] Add logging for job lifecycle events +- [ ] Add stale job detection (reclaim jobs from dead workers, LastHeartbeat > 5 min) +- [ ] Add comprehensive logging for job lifecycle events +- [ ] Include structured logging: JobId, BundleRequestId, UserId, WorkerId, Duration -## Phase 6: Deployment +## Phase 6: SignalR & Notifications -- [ ] Create Dockerfile for `Managing.Compute` -- [ ] Create deployment configuration for compute workers -- [ ] Configure environment variables for compute cluster -- [ ] Set up monitoring dashboards (Prometheus/Grafana) -- [ ] Configure auto-scaling rules for compute workers +- [ ] Inject `IHubContext` into compute worker or executor +- [ ] Send SignalR progress updates during job execution +- [ ] Update `BacktestJob.ProgressPercentage` in database +- [ ] Update `BundleBacktestRequest` progress when jobs complete +- [ ] Send completion notifications via SignalR and Telegram -## Phase 7: Testing & Validation +## Phase 7: Deployment -- [ ] Test single backtest job processing -- [ ] Test bundle backtest with multiple jobs -- [ ] Test concurrent job processing (multiple workers) -- [ ] Test job recovery after worker failure -- [ ] Test priority queue ordering -- [ ] Load test with 1000+ concurrent users +- [ ] Create Dockerfile for `Managing.Compute` (or update existing) +- [ ] Update `docker-compose.yml` to add compute worker service +- [ ] Configure environment variables: `MaxConcurrentBacktests`, `JobPollIntervalSeconds`, `WorkerId` +- [ ] Set up health check configuration in Docker +- [ ] Configure auto-scaling rules for compute workers (min: 1, max: 10) -## Phase 8: Migration Strategy +## Phase 9: Testing & Validation -- [ ] Keep Orleans grains as fallback during transition -- [ ] Feature flag to switch between Orleans and Compute workers -- [ ] Gradual migration: test with small percentage of traffic -- [ ] Monitor performance and error rates -- [ ] Full cutover once validated +- [ ] Unit tests: BacktestJobRepository (advisory locks, job claiming, stale detection) +- [ ] Unit tests: BacktestExecutor (core logic, progress callbacks) +- [ ] Integration tests: Single backtest job processing +- [ ] Integration tests: Bundle backtest with multiple jobs +- [ ] Integration tests: Concurrent job processing (multiple workers) +- [ ] Integration tests: Job recovery after worker failure +- [ ] Integration tests: Priority queue ordering +- [ ] Load tests: 100+ concurrent users, 1000+ pending jobs, multiple workers + +## Phase 8: Cleanup & Removal + +- [ ] Remove or deprecate `BacktestTradingBotGrain.cs` (no longer used) +- [ ] Remove or deprecate `BundleBacktestGrain.cs` (replaced by compute workers) +- [ ] Remove Orleans grain interfaces for backtests (if not used elsewhere) +- [ ] Update `ApiBootstrap.cs` to remove Orleans backtest grain registrations +- [ ] Remove Orleans dependencies from `Backtester.cs` (keep for other operations) +- [ ] Update documentation to reflect new architecture diff --git a/src/Dockerfile-worker-api-dev b/src/Dockerfile-worker-api-dev index 48b0ee76..3fa78b3b 100644 --- a/src/Dockerfile-worker-api-dev +++ b/src/Dockerfile-worker-api-dev @@ -1,34 +1,31 @@ -# Use the official Microsoft ASP.NET Core runtime as the base image. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +# Use the official Microsoft .NET runtime as the base image (no ASP.NET needed for console worker) +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base WORKDIR /app -EXPOSE 80 -EXPOSE 443 # Use the official Microsoft .NET SDK image to build the code. FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /buildapp -COPY ["/src/Managing.Api.Workers/Managing.Api.Workers.csproj", "Managing.Api.Workers/"] +COPY ["/src/Managing.Workers.Api/Managing.Workers.Api.csproj", "Managing.Workers.Api/"] COPY ["/src/Managing.Bootstrap/Managing.Bootstrap.csproj", "Managing.Bootstrap/"] -COPY ["/src/Managing.Infrastructure.Storage/Managing.Infrastructure.Storage.csproj", "Managing.Infrastructure.Storage/"] COPY ["/src/Managing.Application/Managing.Application.csproj", "Managing.Application/"] +COPY ["/src/Managing.Application.Abstractions/Managing.Application.Abstractions.csproj", "Managing.Application.Abstractions/"] COPY ["/src/Managing.Common/Managing.Common.csproj", "Managing.Common/"] COPY ["/src/Managing.Core/Managing.Core.csproj", "Managing.Core/"] -COPY ["/src/Managing.Application.Abstractions/Managing.Application.Abstractions.csproj", "Managing.Application.Abstractions/"] COPY ["/src/Managing.Domain/Managing.Domain.csproj", "Managing.Domain/"] -COPY ["/src/Managing.Infrastructure.Messengers/Managing.Infrastructure.Messengers.csproj", "Managing.Infrastructure.Messengers/"] -COPY ["/src/Managing.Infrastructure.Exchanges/Managing.Infrastructure.Exchanges.csproj", "Managing.Infrastructure.Exchanges/"] COPY ["/src/Managing.Infrastructure.Database/Managing.Infrastructure.Databases.csproj", "Managing.Infrastructure.Database/"] -RUN dotnet restore "/buildapp/Managing.Api.Workers/Managing.Api.Workers.csproj" +COPY ["/src/Managing.Infrastructure.Exchanges/Managing.Infrastructure.Exchanges.csproj", "Managing.Infrastructure.Exchanges/"] +COPY ["/src/Managing.Infrastructure.Messengers/Managing.Infrastructure.Messengers.csproj", "Managing.Infrastructure.Messengers/"] +COPY ["/src/Managing.Infrastructure.Storage/Managing.Infrastructure.Storage.csproj", "Managing.Infrastructure.Storage/"] +COPY ["/src/Managing.Infrastructure.Web3/Managing.Infrastructure.Evm.csproj", "Managing.Infrastructure.Web3/"] +RUN dotnet restore "/buildapp/Managing.Workers.Api/Managing.Workers.Api.csproj" COPY . . -WORKDIR "/buildapp/src/Managing.Api.Workers" -RUN dotnet build "Managing.Api.Workers.csproj" -c Release -o /app/build +WORKDIR "/buildapp/src/Managing.Workers.Api" +RUN dotnet build "Managing.Workers.Api.csproj" -c Release -o /app/build FROM build AS publish -RUN dotnet publish "Managing.Api.Workers.csproj" -c Release -o /app/publish +RUN dotnet publish "Managing.Workers.Api.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -#COPY Managing.Api.Workers/managing_cert.pfx . -#COPY /src/appsettings.dev.vm.json ./appsettings.json -ENTRYPOINT ["dotnet", "Managing.Api.Workers.dll"] +ENTRYPOINT ["dotnet", "Managing.Workers.Api.dll"] diff --git a/src/Managing.Api/Controllers/BacktestController.cs b/src/Managing.Api/Controllers/BacktestController.cs index fb532fc7..bee78768 100644 --- a/src/Managing.Api/Controllers/BacktestController.cs +++ b/src/Managing.Api/Controllers/BacktestController.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Managing.Api.Models.Requests; using Managing.Api.Models.Responses; +using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Shared; using Managing.Application.Hubs; @@ -34,6 +35,7 @@ public class BacktestController : BaseController private readonly IAccountService _accountService; private readonly IMoneyManagementService _moneyManagementService; private readonly IGeneticService _geneticService; + private readonly IServiceScopeFactory _serviceScopeFactory; /// /// Initializes a new instance of the class. @@ -51,13 +53,15 @@ public class BacktestController : BaseController IAccountService accountService, IMoneyManagementService moneyManagementService, IGeneticService geneticService, - IUserService userService) : base(userService) + IUserService userService, + IServiceScopeFactory serviceScopeFactory) : base(userService) { _hubContext = hubContext; _backtester = backtester; _accountService = accountService; _moneyManagementService = moneyManagementService; _geneticService = geneticService; + _serviceScopeFactory = serviceScopeFactory; } /// @@ -788,6 +792,51 @@ public class BacktestController : BaseController return Ok(new { Unsubscribed = true, RequestId = requestId }); } + /// + /// Gets the status of a bundle backtest request, aggregating all job statuses. + /// + /// The bundle request ID + /// The bundle status with aggregated job statistics + [HttpGet] + [Route("Bundle/{bundleRequestId}/Status")] + public async Task> GetBundleStatus(string bundleRequestId) + { + if (!Guid.TryParse(bundleRequestId, out var bundleGuid)) + { + return BadRequest("Invalid bundle request ID format. Must be a valid GUID."); + } + + var user = await GetUser(); + var bundleRequest = _backtester.GetBundleBacktestRequestByIdForUser(user, bundleGuid); + + if (bundleRequest == null) + { + return NotFound($"Bundle backtest request with ID {bundleRequestId} not found."); + } + + // Get all jobs for this bundle + using var serviceScope = _serviceScopeFactory.CreateScope(); + var jobRepository = serviceScope.ServiceProvider.GetRequiredService(); + var jobs = await jobRepository.GetByBundleRequestIdAsync(bundleGuid); + + var response = new BundleBacktestStatusResponse + { + BundleRequestId = bundleGuid, + Status = bundleRequest.Status.ToString(), + TotalJobs = jobs.Count(), + CompletedJobs = jobs.Count(j => j.Status == BacktestJobStatus.Completed), + FailedJobs = jobs.Count(j => j.Status == BacktestJobStatus.Failed), + RunningJobs = jobs.Count(j => j.Status == BacktestJobStatus.Running), + PendingJobs = jobs.Count(j => j.Status == BacktestJobStatus.Pending), + ProgressPercentage = bundleRequest.ProgressPercentage, + CreatedAt = bundleRequest.CreatedAt, + CompletedAt = bundleRequest.CompletedAt, + ErrorMessage = bundleRequest.ErrorMessage + }; + + return Ok(response); + } + /// /// Runs a genetic algorithm optimization with the specified configuration. /// This endpoint saves the genetic request to the database and returns the request ID. diff --git a/src/Managing.Api/Controllers/JobController.cs b/src/Managing.Api/Controllers/JobController.cs new file mode 100644 index 00000000..7b6c0528 --- /dev/null +++ b/src/Managing.Api/Controllers/JobController.cs @@ -0,0 +1,288 @@ +#nullable enable +using System.Text.Json; +using Managing.Api.Models.Responses; +using Managing.Application.Abstractions.Repositories; +using Managing.Application.Abstractions.Services; +using Managing.Application.Shared; +using Managing.Domain.Backtests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using static Managing.Common.Enums; + +namespace Managing.Api.Controllers; + +/// +/// Controller for managing job operations. +/// Provides endpoints for querying job status and progress. +/// Requires admin authorization for access. +/// +[ApiController] +[Authorize] +[Route("[controller]")] +[Produces("application/json")] +public class JobController : BaseController +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IAdminConfigurationService _adminService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service for user management. + /// The service scope factory for creating scoped services. + /// The admin configuration service for authorization checks. + /// The logger instance. + public JobController( + IUserService userService, + IServiceScopeFactory serviceScopeFactory, + IAdminConfigurationService adminService, + ILogger logger) : base(userService) + { + _serviceScopeFactory = serviceScopeFactory; + _adminService = adminService; + _logger = logger; + } + + /// + /// Checks if the current user is an admin + /// + private async Task IsUserAdmin() + { + try + { + var user = await GetUser(); + return await _adminService.IsUserAdminAsync(user.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking if user is admin"); + return false; + } + } + + /// + /// Gets the status of a job by its ID. + /// Admin only endpoint. + /// + /// The job ID to query + /// The job status and result if completed + [HttpGet("{jobId}")] + public async Task> GetJobStatus(string jobId) + { + if (!await IsUserAdmin()) + { + _logger.LogWarning("Non-admin user attempted to access job status endpoint"); + return StatusCode(403, new { error = "Only admin users can access job status" }); + } + + if (!Guid.TryParse(jobId, out var jobGuid)) + { + return BadRequest("Invalid job ID format. Must be a valid GUID."); + } + + using var serviceScope = _serviceScopeFactory.CreateScope(); + var jobRepository = serviceScope.ServiceProvider.GetRequiredService(); + var job = await jobRepository.GetByIdAsync(jobGuid); + + if (job == null) + { + return NotFound($"Job with ID {jobId} not found."); + } + + var response = new BacktestJobStatusResponse + { + JobId = job.Id, + Status = job.Status.ToString(), + ProgressPercentage = job.ProgressPercentage, + CreatedAt = job.CreatedAt, + StartedAt = job.StartedAt, + CompletedAt = job.CompletedAt, + ErrorMessage = job.ErrorMessage, + Result = job.Status == BacktestJobStatus.Completed && !string.IsNullOrEmpty(job.ResultJson) + ? JsonSerializer.Deserialize(job.ResultJson) + : null + }; + + return Ok(response); + } + + /// + /// Gets a paginated list of jobs with optional filters and sorting. + /// Admin only endpoint. + /// + /// Page number (defaults to 1) + /// Number of items per page (defaults to 50, max 100) + /// Field to sort by (CreatedAt, StartedAt, CompletedAt, Priority, Status, JobType) - defaults to CreatedAt + /// Sort order - "asc" or "desc" (defaults to "desc") + /// Optional status filter (Pending, Running, Completed, Failed, Cancelled) + /// Optional job type filter (Backtest, GeneticBacktest) + /// Optional user ID filter + /// Optional worker ID filter + /// Optional bundle request ID filter + /// A paginated list of jobs + [HttpGet] + public async Task> GetJobs( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + [FromQuery] string sortBy = "CreatedAt", + [FromQuery] string sortOrder = "desc", + [FromQuery] string? status = null, + [FromQuery] string? jobType = null, + [FromQuery] int? userId = null, + [FromQuery] string? workerId = null, + [FromQuery] string? bundleRequestId = null) + { + if (!await IsUserAdmin()) + { + _logger.LogWarning("Non-admin user attempted to list jobs"); + return StatusCode(403, new { error = "Only admin users can list jobs" }); + } + + // Validate pagination parameters + if (page < 1) + { + return BadRequest("Page must be greater than 0"); + } + + if (pageSize < 1 || pageSize > 100) + { + return BadRequest("Page size must be between 1 and 100"); + } + + if (sortOrder != "asc" && sortOrder != "desc") + { + return BadRequest("Sort order must be 'asc' or 'desc'"); + } + + // Parse status filter + BacktestJobStatus? statusFilter = null; + if (!string.IsNullOrEmpty(status)) + { + if (Enum.TryParse(status, true, out var parsedStatus)) + { + statusFilter = parsedStatus; + } + else + { + return BadRequest($"Invalid status value. Valid values are: {string.Join(", ", Enum.GetNames())}"); + } + } + + // Parse job type filter + JobType? jobTypeFilter = null; + if (!string.IsNullOrEmpty(jobType)) + { + if (Enum.TryParse(jobType, true, out var parsedJobType)) + { + jobTypeFilter = parsedJobType; + } + else + { + return BadRequest($"Invalid job type value. Valid values are: {string.Join(", ", Enum.GetNames())}"); + } + } + + // Parse bundle request ID + Guid? bundleRequestIdFilter = null; + if (!string.IsNullOrEmpty(bundleRequestId)) + { + if (!Guid.TryParse(bundleRequestId, out var bundleGuid)) + { + return BadRequest("Invalid bundle request ID format. Must be a valid GUID."); + } + bundleRequestIdFilter = bundleGuid; + } + + using var serviceScope = _serviceScopeFactory.CreateScope(); + var jobRepository = serviceScope.ServiceProvider.GetRequiredService(); + + var (jobs, totalCount) = await jobRepository.GetPaginatedAsync( + page, + pageSize, + sortBy, + sortOrder, + statusFilter, + jobTypeFilter, + userId, + workerId, + bundleRequestIdFilter); + + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + var response = new PaginatedJobsResponse + { + Jobs = jobs.Select(j => new JobListItemResponse + { + JobId = j.Id, + Status = j.Status.ToString(), + JobType = j.JobType.ToString(), + ProgressPercentage = j.ProgressPercentage, + Priority = j.Priority, + UserId = j.UserId, + BundleRequestId = j.BundleRequestId, + GeneticRequestId = j.GeneticRequestId, + AssignedWorkerId = j.AssignedWorkerId, + CreatedAt = j.CreatedAt, + StartedAt = j.StartedAt, + CompletedAt = j.CompletedAt, + LastHeartbeat = j.LastHeartbeat, + ErrorMessage = j.ErrorMessage, + StartDate = j.StartDate, + EndDate = j.EndDate + }).ToList(), + TotalCount = totalCount, + CurrentPage = page, + PageSize = pageSize, + TotalPages = totalPages, + HasNextPage = page < totalPages, + HasPreviousPage = page > 1 + }; + + return Ok(response); + } + + /// + /// Gets a summary of jobs grouped by status and job type with counts. + /// Admin only endpoint. + /// + /// Summary statistics of jobs + [HttpGet("summary")] + public async Task> GetJobSummary() + { + if (!await IsUserAdmin()) + { + _logger.LogWarning("Non-admin user attempted to get job summary"); + return StatusCode(403, new { error = "Only admin users can access job summary" }); + } + + using var serviceScope = _serviceScopeFactory.CreateScope(); + var jobRepository = serviceScope.ServiceProvider.GetRequiredService(); + + var summary = await jobRepository.GetSummaryAsync(); + + var response = new JobSummaryResponse + { + StatusSummary = summary.StatusCounts.Select(s => new JobStatusSummary + { + Status = s.Status.ToString(), + Count = s.Count + }).ToList(), + JobTypeSummary = summary.JobTypeCounts.Select(j => new JobTypeSummary + { + JobType = j.JobType.ToString(), + Count = j.Count + }).ToList(), + StatusTypeSummary = summary.StatusTypeCounts.Select(st => new JobStatusTypeSummary + { + Status = st.Status.ToString(), + JobType = st.JobType.ToString(), + Count = st.Count + }).ToList(), + TotalJobs = summary.TotalJobs + }; + + return Ok(response); + } +} + diff --git a/src/Managing.Api/Models/Responses/BacktestJobStatusResponse.cs b/src/Managing.Api/Models/Responses/BacktestJobStatusResponse.cs new file mode 100644 index 00000000..059787f1 --- /dev/null +++ b/src/Managing.Api/Models/Responses/BacktestJobStatusResponse.cs @@ -0,0 +1,37 @@ +using Managing.Domain.Backtests; + +namespace Managing.Api.Models.Responses; + +/// +/// Response model for backtest job status +/// +public class BacktestJobStatusResponse +{ + public Guid JobId { get; set; } + public string Status { get; set; } = string.Empty; + public int ProgressPercentage { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? ErrorMessage { get; set; } + public LightBacktest? Result { get; set; } +} + +/// +/// Response model for bundle backtest status +/// +public class BundleBacktestStatusResponse +{ + public Guid BundleRequestId { get; set; } + public string Status { get; set; } = string.Empty; + public int TotalJobs { get; set; } + public int CompletedJobs { get; set; } + public int FailedJobs { get; set; } + public int RunningJobs { get; set; } + public int PendingJobs { get; set; } + public double ProgressPercentage { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? ErrorMessage { get; set; } +} + diff --git a/src/Managing.Api/Models/Responses/PaginatedJobsResponse.cs b/src/Managing.Api/Models/Responses/PaginatedJobsResponse.cs new file mode 100644 index 00000000..5b4428cc --- /dev/null +++ b/src/Managing.Api/Models/Responses/PaginatedJobsResponse.cs @@ -0,0 +1,79 @@ +#nullable enable +namespace Managing.Api.Models.Responses; + +/// +/// Response model for paginated jobs list +/// +public class PaginatedJobsResponse +{ + public List Jobs { get; set; } = new(); + public int TotalCount { get; set; } + public int CurrentPage { get; set; } + public int PageSize { get; set; } + public int TotalPages { get; set; } + public bool HasNextPage { get; set; } + public bool HasPreviousPage { get; set; } +} + +/// +/// Response model for a job list item (summary view) +/// +public class JobListItemResponse +{ + public Guid JobId { get; set; } + public string Status { get; set; } = string.Empty; + public string JobType { get; set; } = string.Empty; + public int ProgressPercentage { get; set; } + public int Priority { get; set; } + public int UserId { get; set; } + public Guid? BundleRequestId { get; set; } + public string? GeneticRequestId { get; set; } + public string? AssignedWorkerId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public DateTime? LastHeartbeat { get; set; } + public string? ErrorMessage { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } +} + +/// +/// Response model for job summary statistics +/// +public class JobSummaryResponse +{ + public List StatusSummary { get; set; } = new(); + public List JobTypeSummary { get; set; } = new(); + public List StatusTypeSummary { get; set; } = new(); + public int TotalJobs { get; set; } +} + +/// +/// Summary of jobs by status +/// +public class JobStatusSummary +{ + public string Status { get; set; } = string.Empty; + public int Count { get; set; } +} + +/// +/// Summary of jobs by job type +/// +public class JobTypeSummary +{ + public string JobType { get; set; } = string.Empty; + public int Count { get; set; } +} + +/// +/// Summary of jobs by status and job type combination +/// +public class JobStatusTypeSummary +{ + public string Status { get; set; } = string.Empty; + public string JobType { get; set; } = string.Empty; + public int Count { get; set; } +} + diff --git a/src/Managing.Application.Abstractions/Repositories/IBacktestJobRepository.cs b/src/Managing.Application.Abstractions/Repositories/IBacktestJobRepository.cs new file mode 100644 index 00000000..e6b135ea --- /dev/null +++ b/src/Managing.Application.Abstractions/Repositories/IBacktestJobRepository.cs @@ -0,0 +1,134 @@ +using Managing.Domain.Backtests; +using static Managing.Common.Enums; + +namespace Managing.Application.Abstractions.Repositories; + +/// +/// Repository interface for managing backtest jobs in the queue system +/// +public interface IBacktestJobRepository +{ + /// + /// Creates a new backtest job + /// + Task CreateAsync(BacktestJob job); + + /// + /// Claims the next available job using PostgreSQL advisory locks. + /// Returns null if no jobs are available. + /// + /// The ID of the worker claiming the job + /// Optional job type filter. If null, claims any job type. + Task ClaimNextJobAsync(string workerId, JobType? jobType = null); + + /// + /// Updates an existing job + /// + Task UpdateAsync(BacktestJob job); + + /// + /// Gets all jobs for a specific bundle request + /// + Task> GetByBundleRequestIdAsync(Guid bundleRequestId); + + /// + /// Gets all jobs for a specific user + /// + Task> GetByUserIdAsync(int userId); + + /// + /// Gets a job by its ID + /// + Task GetByIdAsync(Guid jobId); + + /// + /// Gets stale jobs (jobs that are Running but haven't sent a heartbeat in the specified timeout) + /// + /// Number of minutes since last heartbeat to consider stale + Task> GetStaleJobsAsync(int timeoutMinutes = 5); + + /// + /// Resets stale jobs back to Pending status + /// + Task ResetStaleJobsAsync(int timeoutMinutes = 5); + + /// + /// Gets all running jobs assigned to a specific worker + /// + Task> GetRunningJobsByWorkerIdAsync(string workerId); + + /// + /// Gets all jobs for a specific genetic request ID + /// + Task> GetByGeneticRequestIdAsync(string geneticRequestId); + + /// + /// Gets paginated jobs with optional filters and sorting + /// + /// Page number (1-based) + /// Number of items per page + /// Field to sort by + /// Sort order ("asc" or "desc") + /// Optional status filter + /// Optional job type filter + /// Optional user ID filter + /// Optional worker ID filter + /// Optional bundle request ID filter + /// Tuple of jobs and total count + Task<(IEnumerable Jobs, int TotalCount)> GetPaginatedAsync( + int page, + int pageSize, + string sortBy = "CreatedAt", + string sortOrder = "desc", + BacktestJobStatus? status = null, + JobType? jobType = null, + int? userId = null, + string? workerId = null, + Guid? bundleRequestId = null); + + /// + /// Gets summary statistics of jobs grouped by status and job type + /// + /// Summary containing counts by status, job type, and their combinations + Task GetSummaryAsync(); +} + +/// +/// Summary statistics for jobs +/// +public class JobSummary +{ + public List StatusCounts { get; set; } = new(); + public List JobTypeCounts { get; set; } = new(); + public List StatusTypeCounts { get; set; } = new(); + public int TotalJobs { get; set; } +} + +/// +/// Count of jobs by status +/// +public class JobStatusCount +{ + public BacktestJobStatus Status { get; set; } + public int Count { get; set; } +} + +/// +/// Count of jobs by job type +/// +public class JobTypeCount +{ + public JobType JobType { get; set; } + public int Count { get; set; } +} + +/// +/// Count of jobs by status and job type combination +/// +public class JobStatusTypeCount +{ + public BacktestJobStatus Status { get; set; } + public JobType JobType { get; set; } + public int Count { get; set; } +} + diff --git a/src/Managing.Application.Tests/BotsTests.cs b/src/Managing.Application.Tests/BotsTests.cs index 8eaf947d..5f217281 100644 --- a/src/Managing.Application.Tests/BotsTests.cs +++ b/src/Managing.Application.Tests/BotsTests.cs @@ -14,6 +14,8 @@ using Managing.Domain.Strategies; using Managing.Domain.Strategies.Signals; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Newtonsoft.Json; using Xunit; @@ -40,12 +42,16 @@ namespace Managing.Application.Tests var hubContext = new Mock>().Object; var tradingBotLogger = TradingBaseTests.CreateTradingBotLogger(); var backtestLogger = TradingBaseTests.CreateBacktesterLogger(); + ILoggerFactory loggerFactory = new NullLoggerFactory(); + var backtestJobLogger = loggerFactory.CreateLogger(); var botService = new Mock().Object; var agentService = new Mock().Object; var _scopeFactory = new Mock(); + var backtestJobRepository = new Mock().Object; + var backtestJobService = new BacktestJobService(backtestJobRepository, backtestRepository, kaigenService, backtestJobLogger); _backtester = new Backtester(_exchangeService, backtestRepository, backtestLogger, - scenarioService, _accountService.Object, messengerService, kaigenService, hubContext, null, - _scopeFactory.Object); + scenarioService, _accountService.Object, messengerService, kaigenService, hubContext, _scopeFactory.Object, + backtestJobService); _elapsedTimes = new List(); // Initialize cross-platform file paths diff --git a/src/Managing.Application/Backtests/BacktestExecutor.cs b/src/Managing.Application/Backtests/BacktestExecutor.cs new file mode 100644 index 00000000..eb1ad1ef --- /dev/null +++ b/src/Managing.Application/Backtests/BacktestExecutor.cs @@ -0,0 +1,280 @@ +using Managing.Application.Abstractions; +using Managing.Application.Abstractions.Repositories; +using Managing.Application.Abstractions.Services; +using Managing.Application.Bots; +using Managing.Common; +using Managing.Domain.Backtests; +using Managing.Domain.Bots; +using Managing.Domain.Candles; +using Managing.Domain.Shared.Helpers; +using Managing.Domain.Users; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Managing.Application.Backtests; + +/// +/// Service for executing backtests without Orleans dependencies. +/// Extracted from BacktestTradingBotGrain to be reusable in compute workers. +/// +public class BacktestExecutor +{ + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IBacktestRepository _backtestRepository; + private readonly IScenarioService _scenarioService; + private readonly IAccountService _accountService; + private readonly IMessengerService _messengerService; + + public BacktestExecutor( + ILogger logger, + IServiceScopeFactory scopeFactory, + IBacktestRepository backtestRepository, + IScenarioService scenarioService, + IAccountService accountService, + IMessengerService messengerService) + { + _logger = logger; + _scopeFactory = scopeFactory; + _backtestRepository = backtestRepository; + _scenarioService = scenarioService; + _accountService = accountService; + _messengerService = messengerService; + } + + /// + /// Executes a backtest with the given configuration and candles. + /// + /// The trading bot configuration + /// The candles to use for backtesting + /// The user running the backtest + /// Whether to save the backtest result + /// Whether to include candles in the result + /// The request ID to associate with this backtest + /// Additional metadata + /// Optional callback for progress updates (0-100) + /// The lightweight backtest result + public async Task ExecuteAsync( + TradingBotConfig config, + HashSet candles, + User user, + bool save = false, + bool withCandles = false, + string requestId = null, + object metadata = null, + Func progressCallback = null) + { + if (candles == null || candles.Count == 0) + { + throw new Exception("No candle to backtest"); + } + + // Ensure user has accounts loaded + if (user.Accounts == null || !user.Accounts.Any()) + { + user.Accounts = (await _accountService.GetAccountsByUserAsync(user, hideSecrets: true, getBalance: false)).ToList(); + } + + // Create a fresh TradingBotBase instance for this backtest + var tradingBot = await CreateTradingBotInstance(config); + tradingBot.Account = user.Accounts.First(); + + var totalCandles = candles.Count; + var currentCandle = 0; + var lastLoggedPercentage = 0; + + _logger.LogInformation("Backtest requested by {UserId} with {TotalCandles} candles for {Ticker} on {Timeframe}", + user.Id, totalCandles, config.Ticker, config.Timeframe); + + // Initialize wallet balance with first candle + tradingBot.WalletBalances.Clear(); + tradingBot.WalletBalances.Add(candles.FirstOrDefault()!.Date, config.BotTradingBalance); + var initialBalance = config.BotTradingBalance; + + var fixedCandles = new HashSet(); + var lastProgressUpdate = DateTime.UtcNow; + const int progressUpdateIntervalMs = 1000; // Update progress every second + + // Process all candles + foreach (var candle in candles) + { + fixedCandles.Add(candle); + tradingBot.LastCandle = candle; + + // Update signals manually only for backtesting + await tradingBot.UpdateSignals(fixedCandles); + await tradingBot.Run(); + + currentCandle++; + + // Update progress callback if provided + var currentPercentage = (currentCandle * 100) / totalCandles; + var timeSinceLastUpdate = (DateTime.UtcNow - lastProgressUpdate).TotalMilliseconds; + if (progressCallback != null && (timeSinceLastUpdate >= progressUpdateIntervalMs || currentPercentage >= lastLoggedPercentage + 10)) + { + try + { + await progressCallback(currentPercentage); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error in progress callback"); + } + lastProgressUpdate = DateTime.UtcNow; + } + + // Log progress every 10% + if (currentPercentage >= lastLoggedPercentage + 10) + { + lastLoggedPercentage = currentPercentage; + _logger.LogInformation( + "Backtest progress: {Percentage}% ({CurrentCandle}/{TotalCandles} candles processed)", + currentPercentage, currentCandle, totalCandles); + } + + // Check if wallet balance fell below 10 USDC and break if so + var currentWalletBalance = tradingBot.WalletBalances.Values.LastOrDefault(); + if (currentWalletBalance < Constants.GMX.Config.MinimumPositionAmount) + { + _logger.LogWarning( + "Backtest stopped early: Wallet balance fell below {MinimumPositionAmount} USDC (Current: {CurrentBalance:F2} USDC) at candle {CurrentCandle}/{TotalCandles} from {CandleDate}", + Constants.GMX.Config.MinimumPositionAmount, currentWalletBalance, currentCandle, totalCandles, + candle.Date.ToString("yyyy-MM-dd HH:mm")); + break; + } + } + + _logger.LogInformation("Backtest processing completed. Calculating final results..."); + + var finalPnl = tradingBot.GetProfitAndLoss(); + var winRate = tradingBot.GetWinRate(); + var stats = TradingHelpers.GetStatistics(tradingBot.WalletBalances); + var growthPercentage = + TradingHelpers.GetGrowthFromInitalBalance(tradingBot.WalletBalances.FirstOrDefault().Value, finalPnl); + var hodlPercentage = TradingHelpers.GetHodlPercentage(candles.First(), candles.Last()); + + var fees = tradingBot.GetTotalFees(); + var scoringParams = new BacktestScoringParams( + sharpeRatio: (double)stats.SharpeRatio, + growthPercentage: (double)growthPercentage, + hodlPercentage: (double)hodlPercentage, + winRate: winRate, + totalPnL: (double)finalPnl, + fees: (double)fees, + tradeCount: tradingBot.Positions.Count, + maxDrawdownRecoveryTime: stats.MaxDrawdownRecoveryTime, + maxDrawdown: stats.MaxDrawdown, + initialBalance: tradingBot.WalletBalances.FirstOrDefault().Value, + tradingBalance: config.BotTradingBalance, + startDate: candles.First().Date, + endDate: candles.Last().Date, + timeframe: config.Timeframe, + moneyManagement: config.MoneyManagement + ); + + var scoringResult = BacktestScorer.CalculateDetailedScore(scoringParams); + + // Generate requestId if not provided + var finalRequestId = requestId != null ? Guid.Parse(requestId) : Guid.NewGuid(); + + // Create backtest result with conditional candles and indicators values + var result = new Backtest(config, tradingBot.Positions, tradingBot.Signals, + withCandles ? candles : new HashSet()) + { + FinalPnl = finalPnl, + WinRate = winRate, + GrowthPercentage = growthPercentage, + HodlPercentage = hodlPercentage, + Fees = fees, + WalletBalances = tradingBot.WalletBalances.ToList(), + Statistics = stats, + Score = scoringResult.Score, + ScoreMessage = scoringResult.GenerateSummaryMessage(), + Id = Guid.NewGuid().ToString(), + RequestId = finalRequestId, + Metadata = metadata, + StartDate = candles.FirstOrDefault()!.OpenTime, + EndDate = candles.LastOrDefault()!.OpenTime, + InitialBalance = initialBalance, + NetPnl = finalPnl - fees, + }; + + if (save && user != null) + { + await _backtestRepository.InsertBacktestForUserAsync(user, result); + } + + // Send notification if backtest meets criteria + await SendBacktestNotificationIfCriteriaMet(result); + + // Convert Backtest to LightBacktest + return ConvertToLightBacktest(result); + } + + /// + /// Converts a Backtest to LightBacktest + /// + private LightBacktest ConvertToLightBacktest(Backtest backtest) + { + return new LightBacktest + { + Id = backtest.Id, + Config = backtest.Config, + FinalPnl = backtest.FinalPnl, + WinRate = backtest.WinRate, + GrowthPercentage = backtest.GrowthPercentage, + HodlPercentage = backtest.HodlPercentage, + StartDate = backtest.StartDate, + EndDate = backtest.EndDate, + MaxDrawdown = backtest.Statistics?.MaxDrawdown, + Fees = backtest.Fees, + SharpeRatio = (double?)backtest.Statistics?.SharpeRatio, + Score = backtest.Score, + ScoreMessage = backtest.ScoreMessage, + InitialBalance = backtest.InitialBalance, + NetPnl = backtest.NetPnl + }; + } + + /// + /// Creates a TradingBotBase instance for backtesting + /// + private async Task CreateTradingBotInstance(TradingBotConfig config) + { + // Validate configuration for backtesting + if (config == null) + { + throw new InvalidOperationException("Bot configuration is not initialized"); + } + + if (!config.IsForBacktest) + { + throw new InvalidOperationException("BacktestExecutor can only be used for backtesting"); + } + + // Create the trading bot instance + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var tradingBot = new TradingBotBase(logger, _scopeFactory, config); + return tradingBot; + } + + /// + /// Sends notification if backtest meets criteria + /// + private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest) + { + try + { + if (backtest.Score > 60) + { + await _messengerService.SendBacktestNotification(backtest); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send backtest notification for backtest {Id}", backtest.Id); + } + } +} + diff --git a/src/Managing.Application/Backtests/BacktestJobService.cs b/src/Managing.Application/Backtests/BacktestJobService.cs new file mode 100644 index 00000000..09fbf5f9 --- /dev/null +++ b/src/Managing.Application/Backtests/BacktestJobService.cs @@ -0,0 +1,254 @@ +using System.Text.Json; +using Managing.Application.Abstractions.Repositories; +using Managing.Application.Abstractions.Services; +using Managing.Domain.Backtests; +using Managing.Domain.Bots; +using Managing.Domain.MoneyManagements; +using Managing.Domain.Scenarios; +using Managing.Domain.Strategies; +using Managing.Domain.Users; +using Microsoft.Extensions.Logging; +using static Managing.Common.Enums; + +namespace Managing.Application.Backtests; + +/// +/// Service for creating and managing backtest jobs in the queue +/// +public class BacktestJobService +{ + private readonly IBacktestJobRepository _jobRepository; + private readonly IBacktestRepository _backtestRepository; + private readonly IKaigenService _kaigenService; + private readonly ILogger _logger; + + public BacktestJobService( + IBacktestJobRepository jobRepository, + IBacktestRepository backtestRepository, + IKaigenService kaigenService, + ILogger logger) + { + _jobRepository = jobRepository; + _backtestRepository = backtestRepository; + _kaigenService = kaigenService; + _logger = logger; + } + + /// + /// Creates a single backtest job + /// + public async Task CreateJobAsync( + TradingBotConfig config, + DateTime startDate, + DateTime endDate, + User user, + int priority = 0, + string requestId = null) + { + // Debit user credits before creating job + string creditRequestId = null; + try + { + creditRequestId = await _kaigenService.DebitUserCreditsAsync(user, 1); + _logger.LogInformation( + "Successfully debited credits for user {UserName} with request ID {RequestId}", + user.Name, creditRequestId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to debit credits for user {UserName}. Job will not be created.", + user.Name); + throw new Exception($"Failed to debit credits: {ex.Message}"); + } + + try + { + var job = new BacktestJob + { + UserId = user.Id, + Status = BacktestJobStatus.Pending, + JobType = JobType.Backtest, + Priority = priority, + ConfigJson = JsonSerializer.Serialize(config), + StartDate = startDate, + EndDate = endDate, + BundleRequestId = null, // Single jobs are not part of a bundle + RequestId = requestId + }; + + var createdJob = await _jobRepository.CreateAsync(job); + _logger.LogInformation("Created backtest job {JobId} for user {UserId}", createdJob.Id, user.Id); + + return createdJob; + } + catch (Exception ex) + { + // If job creation fails, attempt to refund credits + if (!string.IsNullOrEmpty(creditRequestId)) + { + try + { + var refundSuccess = await _kaigenService.RefundUserCreditsAsync(creditRequestId, user); + if (refundSuccess) + { + _logger.LogInformation( + "Successfully refunded credits for user {UserName} after job creation failure", + user.Name); + } + } + catch (Exception refundEx) + { + _logger.LogError(refundEx, "Error during refund attempt for user {UserName}", user.Name); + } + } + + throw; + } + } + + /// + /// Creates multiple backtest jobs from bundle variants + /// + public async Task> CreateBundleJobsAsync( + BundleBacktestRequest bundleRequest, + List backtestRequests) + { + var jobs = new List(); + var creditRequestId = (string?)null; + + try + { + // Debit credits for all jobs upfront + var totalJobs = backtestRequests.Count; + creditRequestId = await _kaigenService.DebitUserCreditsAsync(bundleRequest.User, totalJobs); + _logger.LogInformation( + "Successfully debited {TotalJobs} credits for user {UserName} with request ID {RequestId}", + totalJobs, bundleRequest.User.Name, creditRequestId); + + // Create jobs for each variant + for (int i = 0; i < backtestRequests.Count; i++) + { + var backtestRequest = backtestRequests[i]; + + // Map MoneyManagement + var moneyManagement = backtestRequest.MoneyManagement; + if (moneyManagement == null && backtestRequest.Config.MoneyManagement != null) + { + var mmReq = backtestRequest.Config.MoneyManagement; + moneyManagement = new MoneyManagement + { + Name = mmReq.Name, + Timeframe = mmReq.Timeframe, + StopLoss = mmReq.StopLoss, + TakeProfit = mmReq.TakeProfit, + Leverage = mmReq.Leverage + }; + moneyManagement.FormatPercentage(); + } + + // Map Scenario + LightScenario scenario = null; + if (backtestRequest.Config.Scenario != null) + { + var sReq = backtestRequest.Config.Scenario; + scenario = new LightScenario(sReq.Name, sReq.LoopbackPeriod) + { + Indicators = sReq.Indicators?.Select(ind => new LightIndicator(ind.Name, ind.Type) + { + SignalType = ind.SignalType, + MinimumHistory = ind.MinimumHistory, + Period = ind.Period, + FastPeriods = ind.FastPeriods, + SlowPeriods = ind.SlowPeriods, + SignalPeriods = ind.SignalPeriods, + Multiplier = ind.Multiplier, + SmoothPeriods = ind.SmoothPeriods, + StochPeriods = ind.StochPeriods, + CyclePeriods = ind.CyclePeriods + }).ToList() ?? new List() + }; + } + + // Create TradingBotConfig + var backtestConfig = new TradingBotConfig + { + AccountName = backtestRequest.Config.AccountName, + MoneyManagement = moneyManagement != null + ? new LightMoneyManagement + { + Name = moneyManagement.Name, + Timeframe = moneyManagement.Timeframe, + StopLoss = moneyManagement.StopLoss, + TakeProfit = moneyManagement.TakeProfit, + Leverage = moneyManagement.Leverage + } + : null, + Ticker = backtestRequest.Config.Ticker, + ScenarioName = backtestRequest.Config.ScenarioName, + Scenario = scenario, + Timeframe = backtestRequest.Config.Timeframe, + IsForWatchingOnly = backtestRequest.Config.IsForWatchingOnly, + BotTradingBalance = backtestRequest.Config.BotTradingBalance, + IsForBacktest = true, + CooldownPeriod = backtestRequest.Config.CooldownPeriod ?? 1, + MaxLossStreak = backtestRequest.Config.MaxLossStreak, + MaxPositionTimeHours = backtestRequest.Config.MaxPositionTimeHours, + FlipOnlyWhenInProfit = backtestRequest.Config.FlipOnlyWhenInProfit, + FlipPosition = backtestRequest.Config.FlipPosition, + Name = $"{bundleRequest.Name} #{i + 1}", + CloseEarlyWhenProfitable = backtestRequest.Config.CloseEarlyWhenProfitable, + UseSynthApi = backtestRequest.Config.UseSynthApi, + UseForPositionSizing = backtestRequest.Config.UseForPositionSizing, + UseForSignalFiltering = backtestRequest.Config.UseForSignalFiltering, + UseForDynamicStopLoss = backtestRequest.Config.UseForDynamicStopLoss + }; + + var job = new BacktestJob + { + UserId = bundleRequest.User.Id, + Status = BacktestJobStatus.Pending, + JobType = JobType.Backtest, + Priority = 0, // All bundle jobs have same priority + ConfigJson = JsonSerializer.Serialize(backtestConfig), + StartDate = backtestRequest.StartDate, + EndDate = backtestRequest.EndDate, + BundleRequestId = bundleRequest.RequestId, + RequestId = bundleRequest.RequestId.ToString() + }; + + var createdJob = await _jobRepository.CreateAsync(job); + jobs.Add(createdJob); + } + + _logger.LogInformation( + "Created {JobCount} backtest jobs for bundle request {BundleRequestId}", + jobs.Count, bundleRequest.RequestId); + + return jobs; + } + catch (Exception ex) + { + // If job creation fails, attempt to refund credits + if (!string.IsNullOrEmpty(creditRequestId)) + { + try + { + var refundSuccess = await _kaigenService.RefundUserCreditsAsync(creditRequestId, bundleRequest.User); + if (refundSuccess) + { + _logger.LogInformation( + "Successfully refunded credits for user {UserName} after bundle job creation failure", + bundleRequest.User.Name); + } + } + catch (Exception refundEx) + { + _logger.LogError(refundEx, "Error during refund attempt for user {UserName}", bundleRequest.User.Name); + } + } + + throw; + } + } +} + diff --git a/src/Managing.Application/Backtests/Backtester.cs b/src/Managing.Application/Backtests/Backtester.cs index b0f31210..7319111e 100644 --- a/src/Managing.Application/Backtests/Backtester.cs +++ b/src/Managing.Application/Backtests/Backtester.cs @@ -1,15 +1,14 @@ -using Managing.Application.Abstractions; -using Managing.Application.Abstractions.Grains; +using System.Text.Json; +using Managing.Application.Abstractions; using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Shared; using Managing.Application.Hubs; -using Managing.Core; using Managing.Domain.Accounts; using Managing.Domain.Backtests; using Managing.Domain.Bots; using Managing.Domain.Candles; -using Managing.Domain.Scenarios; +using Managing.Domain.MoneyManagements; using Managing.Domain.Users; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; @@ -30,7 +29,7 @@ namespace Managing.Application.Backtests private readonly IMessengerService _messengerService; private readonly IKaigenService _kaigenService; private readonly IHubContext _hubContext; - private readonly IGrainFactory _grainFactory; + private readonly BacktestJobService _jobService; public Backtester( IExchangeService exchangeService, @@ -41,8 +40,8 @@ namespace Managing.Application.Backtests IMessengerService messengerService, IKaigenService kaigenService, IHubContext hubContext, - IGrainFactory grainFactory, - IServiceScopeFactory serviceScopeFactory) + IServiceScopeFactory serviceScopeFactory, + BacktestJobService jobService) { _exchangeService = exchangeService; _backtestRepository = backtestRepository; @@ -52,23 +51,23 @@ namespace Managing.Application.Backtests _messengerService = messengerService; _kaigenService = kaigenService; _hubContext = hubContext; - _grainFactory = grainFactory; _serviceScopeFactory = serviceScopeFactory; + _jobService = jobService; } /// - /// Runs a trading bot backtest with the specified configuration and date range. - /// Automatically handles different bot types based on config.BotType. + /// Creates a backtest job and returns immediately (fire-and-forget pattern). + /// The job will be processed by compute workers. /// /// The trading bot configuration (must include Scenario object or ScenarioName) /// The start date for the backtest /// The end date for the backtest - /// The user running the backtest (optional) + /// The user running the backtest (required) /// Whether to save the backtest results - /// Whether to include candles and indicators values in the response + /// Whether to include candles and indicators values in the response (ignored, always false for jobs) /// The request ID to associate with this backtest (optional) /// Additional metadata to associate with this backtest (optional) - /// The lightweight backtest results + /// A lightweight backtest response with job ID (result will be available later via GetJobStatus) public async Task RunTradingBotBacktest( TradingBotConfig config, DateTime startDate, @@ -79,59 +78,33 @@ namespace Managing.Application.Backtests string requestId = null, object metadata = null) { - string creditRequestId = null; - - // Debit user credits before starting the backtest - if (user != null) + if (user == null) { - try - { - creditRequestId = await _kaigenService.DebitUserCreditsAsync(user, 1); - _logger.LogInformation( - "Successfully debited credits for user {UserName} with request ID {RequestId}", - user.Name, creditRequestId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to debit credits for user {UserName}. Backtest will not proceed.", - user.Name); - throw new Exception($"Failed to debit credits: {ex.Message}"); - } + throw new ArgumentNullException(nameof(user), "User is required for job-based backtests"); } - try - { - var candles = await GetCandles(config.Ticker, config.Timeframe, startDate, endDate); - return await RunBacktestWithCandles(config, candles, user, save, withCandles, requestId, metadata); - } - catch (Exception ex) - { - // If backtest fails and we debited credits, attempt to refund - if (user != null && !string.IsNullOrEmpty(creditRequestId)) - { - try - { - var refundSuccess = await _kaigenService.RefundUserCreditsAsync(creditRequestId, user); - if (refundSuccess) - { - _logger.LogError( - "Successfully refunded credits for user {UserName} after backtest failure: {message}", - user.Name, ex.Message); - } - else - { - _logger.LogError("Failed to refund credits for user {UserName} after backtest failure", - user.Name); - } - } - catch (Exception refundEx) - { - _logger.LogError(refundEx, "Error during refund attempt for user {UserName}", user.Name); - } - } + // Create a job instead of running synchronously + var job = await _jobService.CreateJobAsync( + config, + startDate, + endDate, + user, + priority: 0, + requestId: requestId); - throw; - } + _logger.LogInformation( + "Created backtest job {JobId} for user {UserId}. Job will be processed by compute workers.", + job.Id, user.Id); + + // Return a placeholder response with job ID + // The actual result will be available via GetJobStatus endpoint + return new LightBacktestResponse + { + Id = job.Id.ToString(), + Config = config, + Score = 0, // Placeholder, actual score will be available when job completes + ScoreMessage = $"Job {job.Id} is queued for processing" + }; } /// @@ -153,67 +126,21 @@ namespace Managing.Application.Backtests string requestId = null, object metadata = null) { - return await RunBacktestWithCandles(config, candles, user, false, withCandles, requestId, metadata); + // This overload is deprecated - use the date range overload which creates a job + // For backward compatibility, create a job with the provided candles date range + if (user == null) + { + throw new ArgumentNullException(nameof(user), "User is required"); + } + + var startDate = candles.Min(c => c.Date); + var endDate = candles.Max(c => c.Date); + + return await RunTradingBotBacktest(config, startDate, endDate, user, false, withCandles, requestId, metadata); } - /// - /// Core backtesting logic - handles the actual backtest execution with pre-loaded candles - /// - private async Task RunBacktestWithCandles( - TradingBotConfig config, - HashSet candles, - User user = null, - bool save = false, - bool withCandles = false, - string requestId = null, - object metadata = null) - { - // Ensure this is a backtest configuration - if (!config.IsForBacktest) - { - throw new InvalidOperationException("Backtest configuration must have IsForBacktest set to true"); - } - - // Validate that scenario and indicators are properly loaded - if (config.Scenario == null && string.IsNullOrEmpty(config.ScenarioName)) - { - throw new InvalidOperationException( - "Backtest configuration must include either Scenario object or ScenarioName"); - } - - if (config.Scenario == null && !string.IsNullOrEmpty(config.ScenarioName)) - { - var fullScenario = await _scenarioService.GetScenarioByNameAndUserAsync(config.ScenarioName, user); - config.Scenario = LightScenario.FromScenario(fullScenario); - } - - // Create a clean copy of the config to avoid Orleans serialization issues - var cleanConfig = CreateCleanConfigForOrleans(config); - - // Create Orleans grain for backtesting - var backtestGrain = _grainFactory.GetGrain(Guid.NewGuid()); - - // Run the backtest using the Orleans grain - var result = await backtestGrain.RunBacktestAsync(cleanConfig, candles, user, save, withCandles, requestId, - metadata); - - // Increment backtest count for the user if user is provided - if (user != null) - { - try - { - await ServiceScopeHelpers.WithScopedService(_serviceScopeFactory, - async (agentService) => await agentService.IncrementBacktestCountAsync(user.Id)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to increment backtest count for user {UserId}", user.Id); - // Don't throw here as the backtest was successful, just log the error - } - } - - return result; - } + // Removed RunBacktestWithCandles - backtests now run via compute workers + // This method is kept for backward compatibility but should not be called directly private async Task> GetCandles(Ticker ticker, Timeframe timeframe, DateTime startDate, DateTime endDate) @@ -229,16 +156,7 @@ namespace Managing.Application.Backtests } - /// - /// Creates a clean copy of the trading bot config for Orleans serialization - /// Uses LightScenario and LightIndicator to avoid FixedSizeQueue serialization issues - /// - private TradingBotConfig CreateCleanConfigForOrleans(TradingBotConfig originalConfig) - { - // Since we're now using LightScenario in TradingBotConfig, we can just return the original config - // The conversion to LightScenario is already done when loading the scenario - return originalConfig; - } + // Removed CreateCleanConfigForOrleans - no longer needed with job queue approach private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest) { @@ -464,8 +382,121 @@ namespace Managing.Application.Backtests if (!saveAsTemplate) { - // Trigger the BundleBacktestGrain to process this request - await TriggerBundleBacktestGrainAsync(bundleRequest.RequestId); + // Generate backtest requests from variants (same logic as BundleBacktestGrain) + var backtestRequests = await GenerateBacktestRequestsFromVariants(bundleRequest); + + if (backtestRequests != null && backtestRequests.Any()) + { + // Create jobs for all variants + await _jobService.CreateBundleJobsAsync(bundleRequest, backtestRequests); + + _logger.LogInformation( + "Created {JobCount} backtest jobs for bundle request {BundleRequestId}", + backtestRequests.Count, bundleRequest.RequestId); + } + } + } + + /// + /// Generates individual backtest requests from variant configuration + /// + private async Task> GenerateBacktestRequestsFromVariants( + BundleBacktestRequest bundleRequest) + { + try + { + // Deserialize the variant configurations + var universalConfig = + JsonSerializer.Deserialize(bundleRequest.UniversalConfigJson); + var dateTimeRanges = JsonSerializer.Deserialize>(bundleRequest.DateTimeRangesJson); + var moneyManagementVariants = + JsonSerializer.Deserialize>(bundleRequest.MoneyManagementVariantsJson); + var tickerVariants = JsonSerializer.Deserialize>(bundleRequest.TickerVariantsJson); + + if (universalConfig == null || dateTimeRanges == null || moneyManagementVariants == null || + tickerVariants == null) + { + _logger.LogError("Failed to deserialize variant configurations for bundle request {RequestId}", + bundleRequest.RequestId); + return new List(); + } + + // Get the first account for the user + var accounts = await _accountService.GetAccountsByUserAsync(bundleRequest.User, hideSecrets: true, getBalance: false); + var firstAccount = accounts.FirstOrDefault(); + + if (firstAccount == null) + { + _logger.LogError("No accounts found for user {UserId} in bundle request {RequestId}", + bundleRequest.User.Id, bundleRequest.RequestId); + return new List(); + } + + var backtestRequests = new List(); + + foreach (var dateRange in dateTimeRanges) + { + foreach (var mmVariant in moneyManagementVariants) + { + foreach (var ticker in tickerVariants) + { + var config = new TradingBotConfigRequest + { + AccountName = firstAccount.Name, + Ticker = ticker, + Timeframe = universalConfig.Timeframe, + IsForWatchingOnly = universalConfig.IsForWatchingOnly, + BotTradingBalance = universalConfig.BotTradingBalance, + Name = + $"{universalConfig.BotName}_{ticker}_{dateRange.StartDate:yyyyMMdd}_{dateRange.EndDate:yyyyMMdd}", + FlipPosition = universalConfig.FlipPosition, + CooldownPeriod = universalConfig.CooldownPeriod, + MaxLossStreak = universalConfig.MaxLossStreak, + Scenario = universalConfig.Scenario, + ScenarioName = universalConfig.ScenarioName, + MoneyManagement = mmVariant.MoneyManagement, + MaxPositionTimeHours = universalConfig.MaxPositionTimeHours, + CloseEarlyWhenProfitable = universalConfig.CloseEarlyWhenProfitable, + FlipOnlyWhenInProfit = universalConfig.FlipOnlyWhenInProfit, + UseSynthApi = universalConfig.UseSynthApi, + UseForPositionSizing = universalConfig.UseForPositionSizing, + UseForSignalFiltering = universalConfig.UseForSignalFiltering, + UseForDynamicStopLoss = universalConfig.UseForDynamicStopLoss + }; + + var backtestRequest = new RunBacktestRequest + { + Config = config, + StartDate = dateRange.StartDate, + EndDate = dateRange.EndDate, + Balance = universalConfig.BotTradingBalance, + WatchOnly = universalConfig.WatchOnly, + Save = universalConfig.Save, + WithCandles = false, + MoneyManagement = mmVariant.MoneyManagement != null + ? new MoneyManagement + { + Name = mmVariant.MoneyManagement.Name, + Timeframe = mmVariant.MoneyManagement.Timeframe, + StopLoss = mmVariant.MoneyManagement.StopLoss, + TakeProfit = mmVariant.MoneyManagement.TakeProfit, + Leverage = mmVariant.MoneyManagement.Leverage + } + : null + }; + + backtestRequests.Add(backtestRequest); + } + } + } + + return backtestRequests; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating backtest requests from variants for bundle request {RequestId}", + bundleRequest.RequestId); + return new List(); } } @@ -530,64 +561,6 @@ namespace Managing.Application.Backtests await _hubContext.Clients.Group($"bundle-{requestId}").SendAsync("BundleBacktestUpdate", response); } - /// - /// Triggers the BundleBacktestGrain to process a bundle request synchronously (fire and forget) - /// - private void TriggerBundleBacktestGrain(Guid bundleRequestId) - { - try - { - var bundleBacktestGrain = _grainFactory.GetGrain(bundleRequestId); - - // Fire and forget - don't await - _ = Task.Run(async () => - { - try - { - await bundleBacktestGrain.ProcessBundleRequestAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error triggering BundleBacktestGrain for request {RequestId}", - bundleRequestId); - } - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TriggerBundleBacktestGrain for request {RequestId}", bundleRequestId); - } - } - - /// - /// Triggers the BundleBacktestGrain to process a bundle request asynchronously - /// - private Task TriggerBundleBacktestGrainAsync(Guid bundleRequestId) - { - try - { - var bundleBacktestGrain = _grainFactory.GetGrain(bundleRequestId); - - // Fire and forget - don't await the actual processing - return Task.Run(async () => - { - try - { - await bundleBacktestGrain.ProcessBundleRequestAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error triggering BundleBacktestGrain for request {RequestId}", - bundleRequestId); - } - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in TriggerBundleBacktestGrainAsync for request {RequestId}", - bundleRequestId); - return Task.CompletedTask; - } - } + // Removed TriggerBundleBacktestGrain methods - bundle backtests now use job queue } } \ No newline at end of file diff --git a/src/Managing.Application/Users/UserService.cs b/src/Managing.Application/Users/UserService.cs index 0a4f5986..5706f950 100644 --- a/src/Managing.Application/Users/UserService.cs +++ b/src/Managing.Application/Users/UserService.cs @@ -17,7 +17,7 @@ public class UserService : IUserService private readonly IAccountService _accountService; private readonly ILogger _logger; private readonly ICacheService _cacheService; - private readonly IGrainFactory _grainFactory; + private readonly IGrainFactory? _grainFactory; private readonly IWhitelistService _whitelistService; private readonly string[] _authorizedAddresses; @@ -27,7 +27,7 @@ public class UserService : IUserService IAccountService accountService, ILogger logger, ICacheService cacheService, - IGrainFactory grainFactory, + IGrainFactory? grainFactory, IWhitelistService whitelistService, IConfiguration configuration) { @@ -134,17 +134,21 @@ public class UserService : IUserService }; // Initialize AgentGrain for new user (with empty agent name initially) - try + // Only if Orleans is available (not available in compute workers) + if (_grainFactory != null) { - var agentGrain = _grainFactory.GetGrain(user.Id); - await agentGrain.InitializeAsync(user.Id, string.Empty); - _logger.LogInformation("AgentGrain initialized for new user {UserId}", user.Id); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to initialize AgentGrain for new user {UserId}", user.Id); - SentrySdk.CaptureException(ex); - // Don't throw here to avoid breaking the user creation process + try + { + var agentGrain = _grainFactory.GetGrain(user.Id); + await agentGrain.InitializeAsync(user.Id, string.Empty); + _logger.LogInformation("AgentGrain initialized for new user {UserId}", user.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize AgentGrain for new user {UserId}", user.Id); + SentrySdk.CaptureException(ex); + // Don't throw here to avoid breaking the user creation process + } } } @@ -199,18 +203,22 @@ public class UserService : IUserService await _userRepository.SaveOrUpdateUserAsync(user); // Update the AgentGrain with the new agent name (lightweight operation) - try + // Only if Orleans is available (not available in compute workers) + if (_grainFactory != null) { - var agentGrain = _grainFactory.GetGrain(user.Id); - await agentGrain.UpdateAgentNameAsync(agentName); - _logger.LogInformation("AgentGrain updated for user {UserId} with agent name {AgentName}", user.Id, - agentName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update AgentGrain for user {UserId} with agent name {AgentName}", - user.Id, agentName); - // Don't throw here to avoid breaking the user update process + try + { + var agentGrain = _grainFactory.GetGrain(user.Id); + await agentGrain.UpdateAgentNameAsync(agentName); + _logger.LogInformation("AgentGrain updated for user {UserId} with agent name {AgentName}", user.Id, + agentName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update AgentGrain for user {UserId} with agent name {AgentName}", + user.Id, agentName); + // Don't throw here to avoid breaking the user update process + } } return user; diff --git a/src/Managing.Application/Workers/BacktestComputeWorker.cs b/src/Managing.Application/Workers/BacktestComputeWorker.cs new file mode 100644 index 00000000..87377df1 --- /dev/null +++ b/src/Managing.Application/Workers/BacktestComputeWorker.cs @@ -0,0 +1,422 @@ +using System.Text.Json; +using Managing.Application.Abstractions.Repositories; +using Managing.Application.Abstractions.Services; +using Managing.Application.Backtests; +using Managing.Domain.Backtests; +using Managing.Domain.Bots; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using static Managing.Common.Enums; + +namespace Managing.Application.Workers; + +/// +/// Background worker that processes backtest jobs from the queue. +/// Polls for pending jobs, claims them using advisory locks, and processes them. +/// +public class BacktestComputeWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly BacktestComputeWorkerOptions _options; + private readonly SemaphoreSlim _semaphore; + + public BacktestComputeWorker( + IServiceScopeFactory scopeFactory, + ILogger logger, + IOptions options) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value; + _semaphore = new SemaphoreSlim(_options.MaxConcurrentBacktests, _options.MaxConcurrentBacktests); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "BacktestComputeWorker starting. WorkerId: {WorkerId}, MaxConcurrent: {MaxConcurrent}, PollInterval: {PollInterval}s", + _options.WorkerId, _options.MaxConcurrentBacktests, _options.JobPollIntervalSeconds); + + // Background task for stale job recovery + var staleJobRecoveryTask = Task.Run(() => StaleJobRecoveryLoop(stoppingToken), stoppingToken); + + // Background task for heartbeat updates + var heartbeatTask = Task.Run(() => HeartbeatLoop(stoppingToken), stoppingToken); + + // Main job processing loop + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessJobsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in BacktestComputeWorker main loop"); + SentrySdk.CaptureException(ex); + } + + await Task.Delay(TimeSpan.FromSeconds(_options.JobPollIntervalSeconds), stoppingToken); + } + + _logger.LogInformation("BacktestComputeWorker stopping"); + } + + private async Task ProcessJobsAsync(CancellationToken cancellationToken) + { + // Check if we have capacity + if (!await _semaphore.WaitAsync(0, cancellationToken)) + { + // At capacity, skip this iteration + return; + } + + try + { + using var scope = _scopeFactory.CreateScope(); + var jobRepository = scope.ServiceProvider.GetRequiredService(); + + // Try to claim a job + var job = await jobRepository.ClaimNextJobAsync(_options.WorkerId); + + if (job == null) + { + // No jobs available, release semaphore + _semaphore.Release(); + return; + } + + _logger.LogInformation("Claimed backtest job {JobId} for worker {WorkerId}", job.Id, _options.WorkerId); + + // Process the job asynchronously (don't await, let it run in background) + // Create a new scope for the job processing to ensure proper lifetime management + _ = Task.Run(async () => + { + try + { + await ProcessJobAsync(job, cancellationToken); + } + finally + { + _semaphore.Release(); + } + }, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error claiming or processing job"); + _semaphore.Release(); + throw; + } + } + + private async Task ProcessJobAsync( + BacktestJob job, + CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var jobRepository = scope.ServiceProvider.GetRequiredService(); + var executor = scope.ServiceProvider.GetRequiredService(); + var userService = scope.ServiceProvider.GetRequiredService(); + var exchangeService = scope.ServiceProvider.GetRequiredService(); + + try + { + _logger.LogInformation( + "Processing backtest job {JobId} (BundleRequestId: {BundleRequestId}, UserId: {UserId})", + job.Id, job.BundleRequestId, job.UserId); + + // Deserialize config + var config = JsonSerializer.Deserialize(job.ConfigJson); + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize TradingBotConfig from job"); + } + + // Load user + var user = await userService.GetUserByIdAsync(job.UserId); + if (user == null) + { + throw new InvalidOperationException($"User {job.UserId} not found"); + } + + // Load candles + var candles = await exchangeService.GetCandlesInflux( + TradingExchanges.Evm, + config.Ticker, + job.StartDate, + config.Timeframe, + job.EndDate); + + if (candles == null || candles.Count == 0) + { + throw new InvalidOperationException( + $"No candles found for {config.Ticker} on {config.Timeframe} from {job.StartDate} to {job.EndDate}"); + } + + // Progress callback to update job progress + Func progressCallback = async (percentage) => + { + try + { + job.ProgressPercentage = percentage; + job.LastHeartbeat = DateTime.UtcNow; + await jobRepository.UpdateAsync(job); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error updating job progress for job {JobId}", job.Id); + } + }; + + // Execute the backtest + var result = await executor.ExecuteAsync( + config, + candles, + user, + save: true, + withCandles: false, + requestId: job.RequestId, + metadata: null, + progressCallback: progressCallback); + + // Update job with result + job.Status = BacktestJobStatus.Completed; + job.ProgressPercentage = 100; + job.ResultJson = JsonSerializer.Serialize(result); + job.CompletedAt = DateTime.UtcNow; + job.LastHeartbeat = DateTime.UtcNow; + + await jobRepository.UpdateAsync(job); + + _logger.LogInformation( + "Completed backtest job {JobId}. Score: {Score}, PnL: {PnL}", + job.Id, result.Score, result.FinalPnl); + + // Update bundle request if this is part of a bundle + if (job.BundleRequestId.HasValue) + { + await UpdateBundleRequestProgress(job.BundleRequestId.Value, scope.ServiceProvider); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing backtest job {JobId}", job.Id); + SentrySdk.CaptureException(ex); + + // Update job status to failed + try + { + job.Status = BacktestJobStatus.Failed; + job.ErrorMessage = ex.Message; + job.CompletedAt = DateTime.UtcNow; + await jobRepository.UpdateAsync(job); + + // Update bundle request if this is part of a bundle + if (job.BundleRequestId.HasValue) + { + await UpdateBundleRequestProgress(job.BundleRequestId.Value, scope.ServiceProvider); + } + } + catch (Exception updateEx) + { + _logger.LogError(updateEx, "Error updating job {JobId} status to failed", job.Id); + } + } + } + + private async Task UpdateBundleRequestProgress(Guid bundleRequestId, IServiceProvider serviceProvider) + { + try + { + var backtestRepository = serviceProvider.GetRequiredService(); + var jobRepository = serviceProvider.GetRequiredService(); + var userService = serviceProvider.GetRequiredService(); + + // Get all jobs for this bundle + var jobs = await jobRepository.GetByBundleRequestIdAsync(bundleRequestId); + var completedJobs = jobs.Count(j => j.Status == BacktestJobStatus.Completed); + var failedJobs = jobs.Count(j => j.Status == BacktestJobStatus.Failed); + var runningJobs = jobs.Count(j => j.Status == BacktestJobStatus.Running); + var totalJobs = jobs.Count(); + + if (totalJobs == 0) + { + return; // No jobs yet + } + + // Get user from first job + var firstJob = jobs.First(); + var user = await userService.GetUserByIdAsync(firstJob.UserId); + if (user == null) + { + _logger.LogWarning("User {UserId} not found for bundle request {BundleRequestId}", firstJob.UserId, bundleRequestId); + return; + } + + // Get bundle request + var bundleRequest = backtestRepository.GetBundleBacktestRequestByIdForUser(user, bundleRequestId); + if (bundleRequest == null) + { + _logger.LogWarning("Bundle request {BundleRequestId} not found for user {UserId}", bundleRequestId, user.Id); + return; + } + + // Update bundle request progress + bundleRequest.CompletedBacktests = completedJobs; + bundleRequest.FailedBacktests = failedJobs; + + // Update status based on job states + if (completedJobs + failedJobs == totalJobs) + { + // All jobs completed or failed + if (failedJobs == 0) + { + bundleRequest.Status = BundleBacktestRequestStatus.Completed; + } + else if (completedJobs == 0) + { + bundleRequest.Status = BundleBacktestRequestStatus.Failed; + bundleRequest.ErrorMessage = "All backtests failed"; + } + else + { + bundleRequest.Status = BundleBacktestRequestStatus.Completed; + bundleRequest.ErrorMessage = $"{failedJobs} backtests failed"; + } + bundleRequest.CompletedAt = DateTime.UtcNow; + bundleRequest.CurrentBacktest = null; + } + else if (runningJobs > 0) + { + // Some jobs still running + bundleRequest.Status = BundleBacktestRequestStatus.Running; + } + + // Update results list from completed jobs + var completedJobResults = jobs + .Where(j => j.Status == BacktestJobStatus.Completed && !string.IsNullOrEmpty(j.ResultJson)) + .Select(j => + { + try + { + var result = JsonSerializer.Deserialize(j.ResultJson); + return result?.Id; + } + catch + { + return null; + } + }) + .Where(id => !string.IsNullOrEmpty(id)) + .ToList(); + + bundleRequest.Results = completedJobResults!; + + await backtestRepository.UpdateBundleBacktestRequestAsync(bundleRequest); + + _logger.LogInformation( + "Updated bundle request {BundleRequestId} progress: {Completed}/{Total} completed, {Failed} failed, {Running} running", + bundleRequestId, completedJobs, totalJobs, failedJobs, runningJobs); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error updating bundle request {BundleRequestId} progress", bundleRequestId); + } + } + + private async Task StaleJobRecoveryLoop(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); // Check every minute + + using var scope = _scopeFactory.CreateScope(); + var jobRepository = scope.ServiceProvider.GetRequiredService(); + + var resetCount = await jobRepository.ResetStaleJobsAsync(_options.StaleJobTimeoutMinutes); + + if (resetCount > 0) + { + _logger.LogInformation("Reset {Count} stale backtest jobs back to Pending status", resetCount); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in stale job recovery loop"); + } + } + } + + private async Task HeartbeatLoop(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds), cancellationToken); + + using var scope = _scopeFactory.CreateScope(); + var jobRepository = scope.ServiceProvider.GetRequiredService(); + + // Update heartbeat for all jobs assigned to this worker + var runningJobs = await jobRepository.GetRunningJobsByWorkerIdAsync(_options.WorkerId); + + foreach (var job in runningJobs) + { + job.LastHeartbeat = DateTime.UtcNow; + await jobRepository.UpdateAsync(job); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in heartbeat loop"); + } + } + } + + public override void Dispose() + { + _semaphore?.Dispose(); + base.Dispose(); + } +} + +/// +/// Configuration options for BacktestComputeWorker +/// +public class BacktestComputeWorkerOptions +{ + public const string SectionName = "BacktestComputeWorker"; + + /// + /// Unique identifier for this worker instance + /// + public string WorkerId { get; set; } = Environment.MachineName; + + /// + /// Maximum number of concurrent backtests to process + /// + public int MaxConcurrentBacktests { get; set; } = 6; + + /// + /// Interval in seconds between job polling attempts + /// + public int JobPollIntervalSeconds { get; set; } = 5; + + /// + /// Interval in seconds between heartbeat updates + /// + public int HeartbeatIntervalSeconds { get; set; } = 30; + + /// + /// Timeout in minutes for considering a job stale + /// + public int StaleJobTimeoutMinutes { get; set; } = 5; +} + diff --git a/src/Managing.Bootstrap/ApiBootstrap.cs b/src/Managing.Bootstrap/ApiBootstrap.cs index fee3314b..80cdbdc0 100644 --- a/src/Managing.Bootstrap/ApiBootstrap.cs +++ b/src/Managing.Bootstrap/ApiBootstrap.cs @@ -400,6 +400,7 @@ public static class ApiBootstrap // Processors services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -442,6 +443,7 @@ public static class ApiBootstrap services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Managing.Bootstrap/ComputeBootstrap.cs b/src/Managing.Bootstrap/ComputeBootstrap.cs new file mode 100644 index 00000000..c011dbc9 --- /dev/null +++ b/src/Managing.Bootstrap/ComputeBootstrap.cs @@ -0,0 +1,170 @@ +using Managing.Application.Abstractions; +using Managing.Application.Abstractions.Repositories; +using Managing.Application.Abstractions.Services; +using Managing.Application.Accounts; +using Managing.Application.Backtests; +using Managing.Application.MoneyManagements; +using Managing.Application.Scenarios; +using Managing.Application.Shared; +using Managing.Application.Synth; +using Managing.Application.Trading; +using Managing.Application.Users; +using Managing.Application.Whitelist; +using Managing.Domain.Statistics; +using Managing.Domain.Trades; +using Managing.Infrastructure.Databases; +using Managing.Infrastructure.Databases.InfluxDb; +using Managing.Infrastructure.Databases.InfluxDb.Abstractions; +using Managing.Infrastructure.Databases.InfluxDb.Models; +using Managing.Infrastructure.Databases.PostgreSql; +using Managing.Infrastructure.Databases.PostgreSql.Configurations; +using Managing.Infrastructure.Evm; +using Managing.Infrastructure.Evm.Services; +using Managing.Infrastructure.Evm.Subgraphs; +using Managing.Infrastructure.Exchanges; +using Managing.Infrastructure.Exchanges.Abstractions; +using Managing.Infrastructure.Exchanges.Exchanges; +using Managing.Infrastructure.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using static Managing.Common.Enums; + +namespace Managing.Bootstrap; + +/// +/// Bootstrap configuration for compute worker project (no Orleans) +/// +public static class ComputeBootstrap +{ + public static IServiceCollection RegisterComputeDependencies(this IServiceCollection services, + IConfiguration configuration) + { + return services + .AddApplication() + .AddInfrastructure(configuration); + } + + private static IServiceCollection AddApplication(this IServiceCollection services) + { + // Core services needed for backtest execution + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Services not needed for compute worker (depend on IBacktester/Orleans) + // services.AddScoped(); // Requires IBacktester + // services.AddScoped(); // Requires IBacktester + // services.AddScoped(); // Requires IBacktester + // services.AddScoped(); // May require Orleans + // services.AddScoped(); // May require Orleans + // services.AddScoped(); // May require Orleans + // services.AddScoped(); // May require Orleans + + // Processors + // Note: IBacktester not needed for compute worker - BacktestExecutor is used directly + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + + // Web3Proxy service (needed for EvmManager) + services.AddTransient(); + + // Evm services (needed for ExchangeService) + services.AddGbcFeed(); + services.AddUniswapV2(); + services.AddChainlink(); + services.AddChainlinkGmx(); + services.AddSingleton(); + + services.AddTransient(); + services.AddTransient(); + + // Synth services (needed for TradingService) + services.AddScoped(); + services.AddScoped(); + + // No-op implementations for compute worker (no Discord needed) + services.AddSingleton(); + // IGrainFactory is optional in UserService - register as null for compute workers + services.AddSingleton(sp => null!); + + // Webhook service (required for notifications) + services.AddHttpClient(); + // MessengerService must be Scoped because it depends on IUserService which is Scoped + services.AddScoped(); + + return services; + } + + private static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + // Database + services.AddSingleton(sp => + sp.GetRequiredService>().Value); + + services.AddSingleton(sp => + sp.GetRequiredService>().Value); + + services.Configure(configuration.GetSection("Kaigen")); + services.Configure(configuration.GetSection("Web3Proxy")); + + // SQL Monitoring (required by repositories) + services.Configure(configuration.GetSection("SqlMonitoring")); + services.AddSingleton(); + + // PostgreSql Repositories + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // InfluxDb Repositories + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Cache + services.AddDistributedMemoryCache(); + services.AddTransient(); + services.AddSingleton(); + + return services; + } +} + +/// +/// No-op implementation of IDiscordService for compute workers +/// +internal class NoOpDiscordService : IDiscordService +{ + public Task SendSignal(string message, TradingExchanges exchange, Ticker ticker, TradeDirection direction, Timeframe timeframe) => Task.CompletedTask; + public Task SendPosition(Position position) => Task.CompletedTask; + public Task SendClosingPosition(Position position) => Task.CompletedTask; + public Task SendMessage(string v) => Task.CompletedTask; + public Task SendTradeMessage(string message, bool isBadBehavior = false) => Task.CompletedTask; + public Task SendIncreasePosition(string address, Trade trade, string copyAccountName, Trade? oldTrade = null) => Task.CompletedTask; + public Task SendClosedPosition(string address, Trade oldTrade) => Task.CompletedTask; + public Task SendDecreasePosition(string address, Trade newTrade, decimal decreaseAmount) => Task.CompletedTask; + public Task SendBestTraders(List traders) => Task.CompletedTask; + public Task SendBadTraders(List traders) => Task.CompletedTask; + public Task SendDowngradedFundingRate(FundingRate oldRate) => Task.CompletedTask; + public Task SendNewTopFundingRate(FundingRate newRate) => Task.CompletedTask; + public Task SendFundingRateUpdate(FundingRate oldRate, FundingRate newRate) => Task.CompletedTask; + public Task SendDebugMessage(string message) => Task.CompletedTask; +} + + diff --git a/src/Managing.Bootstrap/Managing.Bootstrap.csproj b/src/Managing.Bootstrap/Managing.Bootstrap.csproj index 788b87f2..f5a86cb4 100644 --- a/src/Managing.Bootstrap/Managing.Bootstrap.csproj +++ b/src/Managing.Bootstrap/Managing.Bootstrap.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Managing.Common/Enums.cs b/src/Managing.Common/Enums.cs index 42efbe15..7525321b 100644 --- a/src/Managing.Common/Enums.cs +++ b/src/Managing.Common/Enums.cs @@ -550,4 +550,20 @@ public static class Enums InsufficientEthBelowMinimum, BotsHaveOpenPositions } + + /// + /// Type of job in the job queue system + /// + public enum JobType + { + /// + /// Standard backtest job + /// + Backtest, + + /// + /// Genetic algorithm backtest job + /// + GeneticBacktest + } } \ No newline at end of file diff --git a/src/Managing.Domain/Backtests/BacktestJob.cs b/src/Managing.Domain/Backtests/BacktestJob.cs new file mode 100644 index 00000000..030458c1 --- /dev/null +++ b/src/Managing.Domain/Backtests/BacktestJob.cs @@ -0,0 +1,157 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using static Managing.Common.Enums; + +namespace Managing.Domain.Backtests; + +/// +/// Represents a single backtest job in the queue system. +/// Can be a standalone backtest or part of a bundle backtest request. +/// +public class BacktestJob +{ + public BacktestJob() + { + Id = Guid.NewGuid(); + CreatedAt = DateTime.UtcNow; + Status = BacktestJobStatus.Pending; + ProgressPercentage = 0; + } + + /// + /// Unique identifier for the job + /// + [Required] + public Guid Id { get; set; } + + /// + /// Optional bundle request ID if this job is part of a bundle + /// + public Guid? BundleRequestId { get; set; } + + /// + /// User ID who created this job + /// + [Required] + public int UserId { get; set; } + + /// + /// Current status of the job + /// + [Required] + public BacktestJobStatus Status { get; set; } + + /// + /// Priority of the job (higher = more important) + /// + [Required] + public int Priority { get; set; } = 0; + + /// + /// Serialized TradingBotConfig JSON + /// + [Required] + public string ConfigJson { get; set; } = string.Empty; + + /// + /// Start date for the backtest + /// + [Required] + public DateTime StartDate { get; set; } + + /// + /// End date for the backtest + /// + [Required] + public DateTime EndDate { get; set; } + + /// + /// Progress percentage (0-100) + /// + [Required] + public int ProgressPercentage { get; set; } + + /// + /// Worker ID that has claimed this job + /// + public string? AssignedWorkerId { get; set; } + + /// + /// Last heartbeat timestamp from the assigned worker + /// + public DateTime? LastHeartbeat { get; set; } + + /// + /// When the job was created + /// + [Required] + public DateTime CreatedAt { get; set; } + + /// + /// When the job started processing + /// + public DateTime? StartedAt { get; set; } + + /// + /// When the job completed (successfully or failed) + /// + public DateTime? CompletedAt { get; set; } + + /// + /// Serialized LightBacktest result JSON (when completed) + /// + public string? ResultJson { get; set; } + + /// + /// Error message if the job failed + /// + public string? ErrorMessage { get; set; } + + /// + /// Request ID to associate with the backtest result (for grouping) + /// + public string? RequestId { get; set; } + + /// + /// Type of job (Backtest or GeneticBacktest) + /// + [Required] + public JobType JobType { get; set; } = JobType.Backtest; + + /// + /// Optional genetic request ID if this is a genetic backtest job + /// + public string? GeneticRequestId { get; set; } +} + +/// +/// Status of a backtest job +/// +public enum BacktestJobStatus +{ + /// + /// Job is pending and waiting to be claimed by a worker + /// + Pending, + + /// + /// Job is currently being processed by a worker + /// + Running, + + /// + /// Job completed successfully + /// + Completed, + + /// + /// Job failed with an error + /// + Failed, + + /// + /// Job was cancelled + /// + Cancelled +} + diff --git a/src/Managing.Infrastructure.Database/Migrations/20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs.Designer.cs b/src/Managing.Infrastructure.Database/Migrations/20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs.Designer.cs new file mode 100644 index 00000000..93d1d841 --- /dev/null +++ b/src/Managing.Infrastructure.Database/Migrations/20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs.Designer.cs @@ -0,0 +1,1705 @@ +// +using System; +using Managing.Infrastructure.Databases.PostgreSql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Managing.Infrastructure.Databases.Migrations +{ + [DbContext(typeof(ManagingDbContext))] + [Migration("20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs")] + partial class AddJobTypeAndGeneticRequestIdToBacktestJobs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AccountEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exchange") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsGmxInitialized") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Secret") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AgentSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActiveStrategiesCount") + .HasColumnType("integer"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BacktestCount") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Losses") + .HasColumnType("integer"); + + b.Property("NetPnL") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Runtime") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalBalance") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("TotalFees") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("TotalPnL") + .HasColumnType("decimal(18,8)"); + + b.Property("TotalROI") + .HasColumnType("decimal(18,8)"); + + b.Property("TotalVolume") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Wins") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AgentName") + .IsUnique(); + + b.HasIndex("TotalPnL"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("AgentSummaries"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BacktestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0)); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Fees") + .HasColumnType("decimal(18,8)"); + + b.Property("FinalPnl") + .HasColumnType("decimal(18,8)"); + + b.Property("GrowthPercentage") + .HasColumnType("decimal(18,8)"); + + b.Property("HodlPercentage") + .HasColumnType("decimal(18,8)"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndicatorsCount") + .HasColumnType("integer"); + + b.Property("IndicatorsCsv") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitialBalance") + .HasColumnType("decimal(18,8)"); + + b.Property("MaxDrawdown") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,8)") + .HasDefaultValue(0m); + + b.Property("MaxDrawdownRecoveryTime") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0)); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("MoneyManagementJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("NetPnl") + .HasColumnType("decimal(18,8)"); + + b.Property("PositionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.Property("ScoreMessage") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("text"); + + b.Property("SharpeRatio") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,8)") + .HasDefaultValue(0m); + + b.Property("SignalsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StatisticsJson") + .HasColumnType("jsonb"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Timeframe") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("WinRate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("RequestId"); + + b.HasIndex("Score"); + + b.HasIndex("UserId"); + + b.HasIndex("RequestId", "Score"); + + b.HasIndex("UserId", "Name"); + + b.HasIndex("UserId", "Score"); + + b.HasIndex("UserId", "Ticker"); + + b.HasIndex("UserId", "Timeframe"); + + b.ToTable("Backtests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BotEntity", b => + { + b.Property("Identifier") + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("AccumulatedRunTimeSeconds") + .HasColumnType("bigint"); + + b.Property("CreateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Fees") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("LastStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastStopTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LongPositionCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("NetPnL") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Pnl") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Roi") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("ShortPositionCount") + .HasColumnType("integer"); + + b.Property("StartupTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("TradeLosses") + .HasColumnType("integer"); + + b.Property("TradeWins") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Volume") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.HasKey("Identifier"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("Bots"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BundleBacktestRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedBacktests") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentBacktest") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("DateTimeRangesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("EstimatedTimeRemainingSeconds") + .HasColumnType("integer"); + + b.Property("FailedBacktests") + .HasColumnType("integer"); + + b.Property("MoneyManagementVariantsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProgressInfo") + .HasColumnType("text"); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("ResultsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TickerVariantsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalBacktests") + .HasColumnType("integer"); + + b.Property("UniversalConfigJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.HasIndex("UserId", "Name", "Version"); + + b.ToTable("BundleBacktestRequests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.FundingRateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("Exchange") + .HasColumnType("integer"); + + b.Property("OpenInterest") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Rate") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Exchange"); + + b.HasIndex("Ticker"); + + b.HasIndex("Exchange", "Date"); + + b.HasIndex("Ticker", "Exchange"); + + b.HasIndex("Ticker", "Exchange", "Date") + .IsUnique(); + + b.ToTable("FundingRates"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.GeneticRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Balance") + .HasColumnType("decimal(18,8)"); + + b.Property("BestChromosome") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("BestFitness") + .HasColumnType("double precision"); + + b.Property("BestFitnessSoFar") + .HasColumnType("double precision"); + + b.Property("BestIndividual") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrossoverMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("CurrentGeneration") + .HasColumnType("integer"); + + b.Property("EligibleIndicatorsJson") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ElitismPercentage") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Generations") + .HasColumnType("integer"); + + b.Property("MaxTakeProfit") + .HasColumnType("double precision"); + + b.Property("MutationMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("MutationRate") + .HasColumnType("double precision"); + + b.Property("PopulationSize") + .HasColumnType("integer"); + + b.Property("ProgressInfo") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SelectionMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("GeneticRequests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CyclePeriods") + .HasColumnType("integer"); + + b.Property("FastPeriods") + .HasColumnType("integer"); + + b.Property("MinimumHistory") + .HasColumnType("integer"); + + b.Property("Multiplier") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Period") + .HasColumnType("integer"); + + b.Property("SignalPeriods") + .HasColumnType("integer"); + + b.Property("SignalType") + .IsRequired() + .HasColumnType("text"); + + b.Property("SlowPeriods") + .HasColumnType("integer"); + + b.Property("SmoothPeriods") + .HasColumnType("integer"); + + b.Property("StochPeriods") + .HasColumnType("integer"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Name"); + + b.ToTable("Indicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedWorkerId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BundleRequestId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("GeneticRequestId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobType") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("LastHeartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("ProgressPercentage") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ResultJson") + .HasColumnType("jsonb"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BundleRequestId") + .HasDatabaseName("idx_bundle_request"); + + b.HasIndex("GeneticRequestId") + .HasDatabaseName("idx_genetic_request"); + + b.HasIndex("AssignedWorkerId", "Status") + .HasDatabaseName("idx_assigned_worker"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("idx_user_status"); + + b.HasIndex("Status", "JobType", "Priority", "CreatedAt") + .HasDatabaseName("idx_status_jobtype_priority_created"); + + b.ToTable("BacktestJobs"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Leverage") + .HasColumnType("decimal(18,8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StopLoss") + .HasColumnType("decimal(18,8)"); + + b.Property("TakeProfit") + .HasColumnType("decimal(18,8)"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("UserName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserName"); + + b.HasIndex("UserName", "Name"); + + b.ToTable("MoneyManagements"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.PositionEntity", b => + { + b.Property("Identifier") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("GasFees") + .HasColumnType("decimal(18,8)"); + + b.Property("Initiator") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitiatorIdentifier") + .HasColumnType("uuid"); + + b.Property("MoneyManagementJson") + .HasColumnType("text"); + + b.Property("NetPnL") + .HasColumnType("decimal(18,8)"); + + b.Property("OpenTradeId") + .HasColumnType("integer"); + + b.Property("OriginDirection") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfitAndLoss") + .HasColumnType("decimal(18,8)"); + + b.Property("SignalIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StopLossTradeId") + .HasColumnType("integer"); + + b.Property("TakeProfit1TradeId") + .HasColumnType("integer"); + + b.Property("TakeProfit2TradeId") + .HasColumnType("integer"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("UiFees") + .HasColumnType("decimal(18,8)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Identifier"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("InitiatorIdentifier"); + + b.HasIndex("OpenTradeId"); + + b.HasIndex("Status"); + + b.HasIndex("StopLossTradeId"); + + b.HasIndex("TakeProfit1TradeId"); + + b.HasIndex("TakeProfit2TradeId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Identifier"); + + b.ToTable("Positions"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LoopbackPeriod") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Name"); + + b.ToTable("Scenarios"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioIndicatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IndicatorId") + .HasColumnType("integer"); + + b.Property("ScenarioId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IndicatorId"); + + b.HasIndex("ScenarioId", "IndicatorId") + .IsUnique(); + + b.ToTable("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SignalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandleJson") + .HasColumnType("text"); + + b.Property("Confidence") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndicatorName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SignalType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Identifier"); + + b.HasIndex("Status"); + + b.HasIndex("Ticker"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Date"); + + b.HasIndex("Identifier", "Date", "UserId") + .IsUnique(); + + b.ToTable("Signals"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SpotlightOverviewEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Identifier") + .HasColumnType("uuid"); + + b.Property("ScenarioCount") + .HasColumnType("integer"); + + b.Property("SpotlightsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DateTime"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("DateTime", "ScenarioCount"); + + b.ToTable("SpotlightOverviews"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SynthMinersLeaderboardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Asset") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBacktest") + .HasColumnType("boolean"); + + b.Property("MinersData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SignalDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeIncrement") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.ToTable("SynthMinersLeaderboards"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SynthPredictionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Asset") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBacktest") + .HasColumnType("boolean"); + + b.Property("MinerUid") + .HasColumnType("integer"); + + b.Property("PredictionData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SignalDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeIncrement") + .HasColumnType("integer"); + + b.Property("TimeLength") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.ToTable("SynthPredictions"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TopVolumeTickerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Exchange") + .HasColumnType("integer"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Exchange"); + + b.HasIndex("Ticker"); + + b.HasIndex("Date", "Rank"); + + b.HasIndex("Exchange", "Date"); + + b.ToTable("TopVolumeTickers"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExchangeOrderId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Leverage") + .HasColumnType("decimal(18,8)"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("decimal(18,8)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,8)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("TradeType") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("ExchangeOrderId"); + + b.HasIndex("Status"); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TraderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AverageLoss") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("AverageWin") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBestTrader") + .HasColumnType("boolean"); + + b.Property("Pnl") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Roi") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("TradeCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Winrate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Address"); + + b.HasIndex("IsBestTrader"); + + b.HasIndex("Pnl"); + + b.HasIndex("Roi"); + + b.HasIndex("Winrate"); + + b.HasIndex("Address", "IsBestTrader") + .IsUnique(); + + b.HasIndex("IsBestTrader", "Roi"); + + b.HasIndex("IsBestTrader", "Winrate"); + + b.ToTable("Traders"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TelegramChannel") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("AgentName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WhitelistAccountEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmbeddedWallet") + .IsRequired() + .HasMaxLength(42) + .HasColumnType("character varying(42)"); + + b.Property("ExternalEthereumAccount") + .HasMaxLength(42) + .HasColumnType("character varying(42)"); + + b.Property("IsWhitelisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PrivyCreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivyId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TwitterAccount") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EmbeddedWallet") + .IsUnique(); + + b.HasIndex("ExternalEthereumAccount"); + + b.HasIndex("PrivyId") + .IsUnique(); + + b.HasIndex("TwitterAccount"); + + b.ToTable("WhitelistAccounts"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WorkerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DelayTicks") + .HasColumnType("bigint"); + + b.Property("ExecutionCount") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("WorkerType") + .IsUnique(); + + b.ToTable("Workers"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AccountEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AgentSummaryEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BacktestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BotEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BundleBacktestRequestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.GeneticRequestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.PositionEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "OpenTrade") + .WithMany() + .HasForeignKey("OpenTradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "StopLossTrade") + .WithMany() + .HasForeignKey("StopLossTradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "TakeProfit1Trade") + .WithMany() + .HasForeignKey("TakeProfit1TradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "TakeProfit2Trade") + .WithMany() + .HasForeignKey("TakeProfit2TradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("OpenTrade"); + + b.Navigation("StopLossTrade"); + + b.Navigation("TakeProfit1Trade"); + + b.Navigation("TakeProfit2Trade"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioIndicatorEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", "Indicator") + .WithMany("ScenarioIndicators") + .HasForeignKey("IndicatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", "Scenario") + .WithMany("ScenarioIndicators") + .HasForeignKey("ScenarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Indicator"); + + b.Navigation("Scenario"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SignalEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.Navigation("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.Navigation("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", b => + { + b.Navigation("Accounts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Managing.Infrastructure.Database/Migrations/20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs.cs b/src/Managing.Infrastructure.Database/Migrations/20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs.cs new file mode 100644 index 00000000..6b4e9266 --- /dev/null +++ b/src/Managing.Infrastructure.Database/Migrations/20251108162532_AddJobTypeAndGeneticRequestIdToBacktestJobs.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Managing.Infrastructure.Databases.Migrations +{ + /// + public partial class AddJobTypeAndGeneticRequestIdToBacktestJobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BacktestJobs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + BundleRequestId = table.Column(type: "uuid", nullable: true), + UserId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + JobType = table.Column(type: "integer", nullable: false, defaultValue: 0), + Priority = table.Column(type: "integer", nullable: false, defaultValue: 0), + ConfigJson = table.Column(type: "jsonb", nullable: false), + StartDate = table.Column(type: "timestamp with time zone", nullable: false), + EndDate = table.Column(type: "timestamp with time zone", nullable: false), + ProgressPercentage = table.Column(type: "integer", nullable: false, defaultValue: 0), + AssignedWorkerId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + LastHeartbeat = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: true), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + ResultJson = table.Column(type: "jsonb", nullable: true), + ErrorMessage = table.Column(type: "text", nullable: true), + RequestId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + GeneticRequestId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BacktestJobs", x => x.Id); + table.ForeignKey( + name: "FK_BacktestJobs_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "idx_assigned_worker", + table: "BacktestJobs", + columns: new[] { "AssignedWorkerId", "Status" }); + + migrationBuilder.CreateIndex( + name: "idx_bundle_request", + table: "BacktestJobs", + column: "BundleRequestId"); + + migrationBuilder.CreateIndex( + name: "idx_genetic_request", + table: "BacktestJobs", + column: "GeneticRequestId"); + + migrationBuilder.CreateIndex( + name: "idx_status_jobtype_priority_created", + table: "BacktestJobs", + columns: new[] { "Status", "JobType", "Priority", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "idx_user_status", + table: "BacktestJobs", + columns: new[] { "UserId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BacktestJobs"); + } + } +} diff --git a/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs b/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs index 40e35737..327db65b 100644 --- a/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs +++ b/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs @@ -706,6 +706,95 @@ namespace Managing.Infrastructure.Databases.Migrations b.ToTable("Indicators"); }); + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedWorkerId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BundleRequestId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("GeneticRequestId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobType") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("LastHeartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("ProgressPercentage") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ResultJson") + .HasColumnType("jsonb"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BundleRequestId") + .HasDatabaseName("idx_bundle_request"); + + b.HasIndex("GeneticRequestId") + .HasDatabaseName("idx_genetic_request"); + + b.HasIndex("AssignedWorkerId", "Status") + .HasDatabaseName("idx_assigned_worker"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("idx_user_status"); + + b.HasIndex("Status", "JobType", "Priority", "CreatedAt") + .HasDatabaseName("idx_status_jobtype_priority_created"); + + b.ToTable("BacktestJobs"); + }); + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b => { b.Property("Id") @@ -1494,6 +1583,17 @@ namespace Managing.Infrastructure.Databases.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b => { b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") diff --git a/src/Managing.Infrastructure.Database/PostgreSql/Entities/JobEntity.cs b/src/Managing.Infrastructure.Database/PostgreSql/Entities/JobEntity.cs new file mode 100644 index 00000000..bb5d6453 --- /dev/null +++ b/src/Managing.Infrastructure.Database/PostgreSql/Entities/JobEntity.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Managing.Infrastructure.Databases.PostgreSql.Entities; + +[Table("BacktestJobs")] +public class JobEntity +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public Guid Id { get; set; } + + public Guid? BundleRequestId { get; set; } + + [Required] + public int UserId { get; set; } + + [Required] + public int Status { get; set; } // BacktestJobStatus enum as int + + [Required] + public int JobType { get; set; } // JobType enum as int + + [Required] + public int Priority { get; set; } = 0; + + [Required] + [Column(TypeName = "jsonb")] + public string ConfigJson { get; set; } = string.Empty; + + [Required] + public DateTime StartDate { get; set; } + + [Required] + public DateTime EndDate { get; set; } + + [Required] + public int ProgressPercentage { get; set; } = 0; + + [MaxLength(255)] + public string? AssignedWorkerId { get; set; } + + public DateTime? LastHeartbeat { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } + + public DateTime? StartedAt { get; set; } + + public DateTime? CompletedAt { get; set; } + + [Column(TypeName = "jsonb")] + public string? ResultJson { get; set; } + + [Column(TypeName = "text")] + public string? ErrorMessage { get; set; } + + [MaxLength(255)] + public string? RequestId { get; set; } + + [MaxLength(255)] + public string? GeneticRequestId { get; set; } + + // Navigation property + public UserEntity? User { get; set; } +} + diff --git a/src/Managing.Infrastructure.Database/PostgreSql/ManagingDbContext.cs b/src/Managing.Infrastructure.Database/PostgreSql/ManagingDbContext.cs index 438ac8cd..577f369d 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/ManagingDbContext.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/ManagingDbContext.cs @@ -27,6 +27,7 @@ public class ManagingDbContext : DbContext public DbSet GeneticRequests { get; set; } public DbSet Backtests { get; set; } public DbSet BundleBacktestRequests { get; set; } + public DbSet Jobs { get; set; } // Trading entities public DbSet Scenarios { get; set; } @@ -231,6 +232,45 @@ public class ManagingDbContext : DbContext entity.HasIndex(e => new { e.UserId, e.Name, e.Version }); }); + // Configure BacktestJob entity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedNever(); // GUIDs are generated by application + entity.Property(e => e.UserId).IsRequired(); + entity.Property(e => e.Status).IsRequired(); + entity.Property(e => e.JobType).IsRequired().HasDefaultValue(0); // 0 = Backtest + entity.Property(e => e.Priority).IsRequired().HasDefaultValue(0); + entity.Property(e => e.ConfigJson).IsRequired().HasColumnType("jsonb"); + entity.Property(e => e.StartDate).IsRequired(); + entity.Property(e => e.EndDate).IsRequired(); + entity.Property(e => e.ProgressPercentage).IsRequired().HasDefaultValue(0); + entity.Property(e => e.AssignedWorkerId).HasMaxLength(255); + entity.Property(e => e.ResultJson).HasColumnType("jsonb"); + entity.Property(e => e.ErrorMessage).HasColumnType("text"); + entity.Property(e => e.RequestId).HasMaxLength(255); + entity.Property(e => e.GeneticRequestId).HasMaxLength(255); + entity.Property(e => e.CreatedAt).IsRequired(); + + // Indexes for efficient job claiming and queries + entity.HasIndex(e => new { e.Status, e.JobType, e.Priority, e.CreatedAt }) + .HasDatabaseName("idx_status_jobtype_priority_created"); + entity.HasIndex(e => e.BundleRequestId) + .HasDatabaseName("idx_bundle_request"); + entity.HasIndex(e => new { e.AssignedWorkerId, e.Status }) + .HasDatabaseName("idx_assigned_worker"); + entity.HasIndex(e => new { e.UserId, e.Status }) + .HasDatabaseName("idx_user_status"); + entity.HasIndex(e => e.GeneticRequestId) + .HasDatabaseName("idx_genetic_request"); + + // Configure relationship with User + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.SetNull); + }); + // Configure Scenario entity modelBuilder.Entity(entity => { diff --git a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlJobRepository.cs b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlJobRepository.cs new file mode 100644 index 00000000..1cc35f64 --- /dev/null +++ b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlJobRepository.cs @@ -0,0 +1,485 @@ +using Managing.Application.Abstractions.Repositories; +using Managing.Domain.Backtests; +using Managing.Infrastructure.Databases.PostgreSql.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using static Managing.Common.Enums; + +namespace Managing.Infrastructure.Databases.PostgreSql; + +public class PostgreSqlJobRepository : IBacktestJobRepository +{ + private readonly ManagingDbContext _context; + private readonly ILogger _logger; + + public PostgreSqlJobRepository( + ManagingDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task CreateAsync(BacktestJob job) + { + var entity = MapToEntity(job); + _context.Jobs.Add(entity); + await _context.SaveChangesAsync(); + return MapToDomain(entity); + } + + public async Task ClaimNextJobAsync(string workerId, JobType? jobType = null) + { + // Use execution strategy to support retry with transactions + // FOR UPDATE SKIP LOCKED ensures only one worker can claim a specific job + var strategy = _context.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + + try + { + // Build SQL query with optional job type filter + var sql = @" + SELECT * FROM ""BacktestJobs"" + WHERE ""Status"" = {0}"; + + var parameters = new List { (int)BacktestJobStatus.Pending }; + + if (jobType.HasValue) + { + sql += @" AND ""JobType"" = {1}"; + parameters.Add((int)jobType.Value); + } + + sql += @" + ORDER BY ""Priority"" DESC, ""CreatedAt"" ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED"; + + // Use raw SQL with FromSqlRaw to get the next job with row-level locking + var job = await _context.Jobs + .FromSqlRaw(sql, parameters.ToArray()) + .FirstOrDefaultAsync(); + + if (job == null) + { + await transaction.RollbackAsync(); + return null; + } + + // Update the job status atomically + job.Status = (int)BacktestJobStatus.Running; + job.AssignedWorkerId = workerId; + job.StartedAt = DateTime.UtcNow; + job.LastHeartbeat = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + await transaction.CommitAsync(); + + return MapToDomain(job); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Error claiming job for worker {WorkerId}", workerId); + throw; + } + }); + } + + public async Task UpdateAsync(BacktestJob job) + { + // Use AsTracking() to enable change tracking since DbContext uses NoTracking by default + var entity = await _context.Jobs + .AsTracking() + .FirstOrDefaultAsync(e => e.Id == job.Id); + + if (entity == null) + { + _logger.LogWarning("Job {JobId} not found for update", job.Id); + return; + } + + // Update entity properties + entity.Status = (int)job.Status; + entity.JobType = (int)job.JobType; + entity.ProgressPercentage = job.ProgressPercentage; + entity.AssignedWorkerId = job.AssignedWorkerId; + entity.LastHeartbeat = job.LastHeartbeat; + entity.StartedAt = job.StartedAt; + entity.CompletedAt = job.CompletedAt; + entity.ResultJson = job.ResultJson; + entity.ErrorMessage = job.ErrorMessage; + entity.RequestId = job.RequestId; + entity.GeneticRequestId = job.GeneticRequestId; + entity.Priority = job.Priority; + + await _context.SaveChangesAsync(); + } + + public async Task> GetByBundleRequestIdAsync(Guid bundleRequestId) + { + var entities = await _context.Jobs + .Where(j => j.BundleRequestId == bundleRequestId) + .ToListAsync(); + + return entities.Select(MapToDomain); + } + + public async Task> GetByUserIdAsync(int userId) + { + var entities = await _context.Jobs + .Where(j => j.UserId == userId) + .ToListAsync(); + + return entities.Select(MapToDomain); + } + + /// + /// Gets all running jobs assigned to a specific worker + /// + public async Task> GetRunningJobsByWorkerIdAsync(string workerId) + { + var entities = await _context.Jobs + .Where(j => j.AssignedWorkerId == workerId && j.Status == (int)BacktestJobStatus.Running) + .ToListAsync(); + + return entities.Select(MapToDomain); + } + + public async Task> GetByGeneticRequestIdAsync(string geneticRequestId) + { + var entities = await _context.Jobs + .Where(j => j.GeneticRequestId == geneticRequestId) + .ToListAsync(); + + return entities.Select(MapToDomain); + } + + public async Task<(IEnumerable Jobs, int TotalCount)> GetPaginatedAsync( + int page, + int pageSize, + string sortBy = "CreatedAt", + string sortOrder = "desc", + BacktestJobStatus? status = null, + JobType? jobType = null, + int? userId = null, + string? workerId = null, + Guid? bundleRequestId = null) + { + var query = _context.Jobs.AsQueryable(); + + // Apply filters + if (status.HasValue) + { + query = query.Where(j => j.Status == (int)status.Value); + } + + if (jobType.HasValue) + { + query = query.Where(j => j.JobType == (int)jobType.Value); + } + + if (userId.HasValue) + { + query = query.Where(j => j.UserId == userId.Value); + } + + if (!string.IsNullOrEmpty(workerId)) + { + query = query.Where(j => j.AssignedWorkerId == workerId); + } + + if (bundleRequestId.HasValue) + { + query = query.Where(j => j.BundleRequestId == bundleRequestId.Value); + } + + // Get total count before pagination + var totalCount = await query.CountAsync(); + + // Apply sorting + query = sortBy.ToLower() switch + { + "createdat" => sortOrder.ToLower() == "asc" + ? query.OrderBy(j => j.CreatedAt) + : query.OrderByDescending(j => j.CreatedAt), + "startedat" => sortOrder.ToLower() == "asc" + ? query.OrderBy(j => j.StartedAt) + : query.OrderByDescending(j => j.StartedAt), + "completedat" => sortOrder.ToLower() == "asc" + ? query.OrderBy(j => j.CompletedAt) + : query.OrderByDescending(j => j.CompletedAt), + "priority" => sortOrder.ToLower() == "asc" + ? query.OrderBy(j => j.Priority) + : query.OrderByDescending(j => j.Priority), + "status" => sortOrder.ToLower() == "asc" + ? query.OrderBy(j => j.Status) + : query.OrderByDescending(j => j.Status), + "jobtype" => sortOrder.ToLower() == "asc" + ? query.OrderBy(j => j.JobType) + : query.OrderByDescending(j => j.JobType), + _ => query.OrderByDescending(j => j.CreatedAt) // Default sort + }; + + // Apply pagination + var entities = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var jobs = entities.Select(MapToDomain); + + return (jobs, totalCount); + } + + public async Task GetByIdAsync(Guid jobId) + { + var entity = await _context.Jobs + .FirstOrDefaultAsync(j => j.Id == jobId); + + return entity != null ? MapToDomain(entity) : null; + } + + public async Task> GetStaleJobsAsync(int timeoutMinutes = 5) + { + var timeoutThreshold = DateTime.UtcNow.AddMinutes(-timeoutMinutes); + + var entities = await _context.Jobs + .Where(j => j.Status == (int)BacktestJobStatus.Running && + (j.LastHeartbeat == null || j.LastHeartbeat < timeoutThreshold)) + .ToListAsync(); + + return entities.Select(MapToDomain); + } + + public async Task ResetStaleJobsAsync(int timeoutMinutes = 5) + { + var timeoutThreshold = DateTime.UtcNow.AddMinutes(-timeoutMinutes); + + // Use AsTracking() to enable change tracking since DbContext uses NoTracking by default + var staleJobs = await _context.Jobs + .AsTracking() + .Where(j => j.Status == (int)BacktestJobStatus.Running && + (j.LastHeartbeat == null || j.LastHeartbeat < timeoutThreshold)) + .ToListAsync(); + + foreach (var job in staleJobs) + { + job.Status = (int)BacktestJobStatus.Pending; + job.AssignedWorkerId = null; + job.LastHeartbeat = null; + } + + var count = staleJobs.Count; + if (count > 0) + { + await _context.SaveChangesAsync(); + _logger.LogInformation("Reset {Count} stale jobs back to Pending status", count); + } + + return count; + } + + public async Task GetSummaryAsync() + { + // Use ADO.NET directly for aggregation queries to avoid EF Core mapping issues + var connection = _context.Database.GetDbConnection(); + await connection.OpenAsync(); + + try + { + var statusCounts = new List(); + var jobTypeCounts = new List(); + var statusTypeCounts = new List(); + var totalJobs = 0; + + // Query 1: Status summary + var statusSummarySql = @" + SELECT ""Status"", COUNT(*) as Count + FROM ""BacktestJobs"" + GROUP BY ""Status"" + ORDER BY ""Status"""; + + using (var command = connection.CreateCommand()) + { + command.CommandText = statusSummarySql; + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + statusCounts.Add(new StatusCountResult + { + Status = reader.GetInt32(0), + Count = reader.GetInt32(1) + }); + } + } + } + + // Query 2: Job type summary + var jobTypeSummarySql = @" + SELECT ""JobType"", COUNT(*) as Count + FROM ""BacktestJobs"" + GROUP BY ""JobType"" + ORDER BY ""JobType"""; + + using (var command = connection.CreateCommand()) + { + command.CommandText = jobTypeSummarySql; + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + jobTypeCounts.Add(new JobTypeCountResult + { + JobType = reader.GetInt32(0), + Count = reader.GetInt32(1) + }); + } + } + } + + // Query 3: Status + Job type summary + var statusTypeSummarySql = @" + SELECT ""Status"", ""JobType"", COUNT(*) as Count + FROM ""BacktestJobs"" + GROUP BY ""Status"", ""JobType"" + ORDER BY ""Status"", ""JobType"""; + + using (var command = connection.CreateCommand()) + { + command.CommandText = statusTypeSummarySql; + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + statusTypeCounts.Add(new StatusTypeCountResult + { + Status = reader.GetInt32(0), + JobType = reader.GetInt32(1), + Count = reader.GetInt32(2) + }); + } + } + } + + // Query 4: Total count + var totalCountSql = @" + SELECT COUNT(*) as Count + FROM ""BacktestJobs"""; + + using (var command = connection.CreateCommand()) + { + command.CommandText = totalCountSql; + var result = await command.ExecuteScalarAsync(); + totalJobs = result != null ? Convert.ToInt32(result) : 0; + } + + return new JobSummary + { + StatusCounts = statusCounts.Select(s => new JobStatusCount + { + Status = (BacktestJobStatus)s.Status, + Count = s.Count + }).ToList(), + JobTypeCounts = jobTypeCounts.Select(j => new JobTypeCount + { + JobType = (JobType)j.JobType, + Count = j.Count + }).ToList(), + StatusTypeCounts = statusTypeCounts.Select(st => new JobStatusTypeCount + { + Status = (BacktestJobStatus)st.Status, + JobType = (JobType)st.JobType, + Count = st.Count + }).ToList(), + TotalJobs = totalJobs + }; + } + finally + { + await connection.CloseAsync(); + } + } + + // Helper classes for raw SQL query results + private class StatusCountResult + { + public int Status { get; set; } + public int Count { get; set; } + } + + private class JobTypeCountResult + { + public int JobType { get; set; } + public int Count { get; set; } + } + + private class StatusTypeCountResult + { + public int Status { get; set; } + public int JobType { get; set; } + public int Count { get; set; } + } + + private class TotalCountResult + { + public int Count { get; set; } + } + + private static JobEntity MapToEntity(BacktestJob job) + { + return new JobEntity + { + Id = job.Id, + BundleRequestId = job.BundleRequestId, + UserId = job.UserId, + Status = (int)job.Status, + JobType = (int)job.JobType, + Priority = job.Priority, + ConfigJson = job.ConfigJson, + StartDate = job.StartDate, + EndDate = job.EndDate, + ProgressPercentage = job.ProgressPercentage, + AssignedWorkerId = job.AssignedWorkerId, + LastHeartbeat = job.LastHeartbeat, + CreatedAt = job.CreatedAt, + StartedAt = job.StartedAt, + CompletedAt = job.CompletedAt, + ResultJson = job.ResultJson, + ErrorMessage = job.ErrorMessage, + RequestId = job.RequestId, + GeneticRequestId = job.GeneticRequestId + }; + } + + private static BacktestJob MapToDomain(JobEntity entity) + { + return new BacktestJob + { + Id = entity.Id, + BundleRequestId = entity.BundleRequestId, + UserId = entity.UserId, + Status = (BacktestJobStatus)entity.Status, + JobType = (JobType)entity.JobType, + Priority = entity.Priority, + ConfigJson = entity.ConfigJson, + StartDate = entity.StartDate, + EndDate = entity.EndDate, + ProgressPercentage = entity.ProgressPercentage, + AssignedWorkerId = entity.AssignedWorkerId, + LastHeartbeat = entity.LastHeartbeat, + CreatedAt = entity.CreatedAt, + StartedAt = entity.StartedAt, + CompletedAt = entity.CompletedAt, + ResultJson = entity.ResultJson, + ErrorMessage = entity.ErrorMessage, + RequestId = entity.RequestId, + GeneticRequestId = entity.GeneticRequestId + }; + } +} + diff --git a/src/Managing.WebApp/package.json b/src/Managing.WebApp/package.json index 92115511..403b783a 100644 --- a/src/Managing.WebApp/package.json +++ b/src/Managing.WebApp/package.json @@ -78,7 +78,7 @@ "@vitejs/plugin-react": "^1.3.2", "all-contributors-cli": "^6.20.0", "autoprefixer": "^10.4.7", - "daisyui": "^3.5.1", + "daisyui": "^5.4.7", "postcss": "^8.4.13", "prettier": "^2.6.1", "prettier-plugin-tailwind-css": "^1.5.0", diff --git a/src/Managing.WebApp/src/components/mollecules/BottomMenuBar/BottomMenuBar.tsx b/src/Managing.WebApp/src/components/mollecules/BottomMenuBar/BottomMenuBar.tsx new file mode 100644 index 00000000..fd79b6e0 --- /dev/null +++ b/src/Managing.WebApp/src/components/mollecules/BottomMenuBar/BottomMenuBar.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +interface BottomMenuBarProps { + children: React.ReactNode +} + +const BottomMenuBar: React.FC = ({ children }) => { + return ( +
    + {children} +
+ ) +} + +export default BottomMenuBar + diff --git a/src/Managing.WebApp/src/components/mollecules/BottomMenuBar/index.tsx b/src/Managing.WebApp/src/components/mollecules/BottomMenuBar/index.tsx new file mode 100644 index 00000000..c58b5fcb --- /dev/null +++ b/src/Managing.WebApp/src/components/mollecules/BottomMenuBar/index.tsx @@ -0,0 +1,2 @@ +export { default } from './BottomMenuBar' + diff --git a/src/Managing.WebApp/src/components/mollecules/Tabs/Tabs.tsx b/src/Managing.WebApp/src/components/mollecules/Tabs/Tabs.tsx index 72d8e278..289bc999 100644 --- a/src/Managing.WebApp/src/components/mollecules/Tabs/Tabs.tsx +++ b/src/Managing.WebApp/src/components/mollecules/Tabs/Tabs.tsx @@ -36,7 +36,7 @@ const Tabs: FC = ({ {tabs.map((tab: any) => ( + + ) + })} + + ) +} + +export default UserActionsButton + diff --git a/src/Managing.WebApp/src/components/mollecules/UserActionsButton/index.tsx b/src/Managing.WebApp/src/components/mollecules/UserActionsButton/index.tsx new file mode 100644 index 00000000..ad83a6c9 --- /dev/null +++ b/src/Managing.WebApp/src/components/mollecules/UserActionsButton/index.tsx @@ -0,0 +1,3 @@ +export { default } from './UserActionsButton' +export type { UserActionsButtonProps, UserAction } from './UserActionsButton' + diff --git a/src/Managing.WebApp/src/components/mollecules/index.tsx b/src/Managing.WebApp/src/components/mollecules/index.tsx index eced19e7..1c7e4fa7 100644 --- a/src/Managing.WebApp/src/components/mollecules/index.tsx +++ b/src/Managing.WebApp/src/components/mollecules/index.tsx @@ -15,3 +15,6 @@ export { default as Card } from './Card/Card' export { default as ConfigDisplayModal } from './ConfigDisplayModal/ConfigDisplayModal' export { default as IndicatorsDisplay } from './IndicatorsDisplay/IndicatorsDisplay' export { default as PlatformLineChart } from './PlatformLineChart/PlatformLineChart' +export { default as UserActionsButton } from './UserActionsButton/UserActionsButton' +export type { UserActionsButtonProps, UserAction } from './UserActionsButton/UserActionsButton' +export { default as BottomMenuBar } from './BottomMenuBar/BottomMenuBar' diff --git a/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx b/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx index aa8d8e3c..cd447473 100644 --- a/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx +++ b/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx @@ -160,11 +160,12 @@ interface BacktestTableProps { durationMinDays?: number | null durationMaxDays?: number | null } + openFiltersTrigger?: number // When this changes, open the filter sidebar } -const BacktestTable: React.FC = ({list, isFetching, onSortChange, currentSort, onBacktestDeleted, onFiltersChange, filters}) => { +const BacktestTable: React.FC = ({list, isFetching, onSortChange, currentSort, onBacktestDeleted, onFiltersChange, filters, openFiltersTrigger}) => { const [rows, setRows] = useState([]) const {apiUrl} = useApiUrlStore() const {removeBacktest} = useBacktestStore() @@ -198,6 +199,41 @@ const BacktestTable: React.FC = ({list, isFetching, onSortCh const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + // Clear all filters function + const clearAllFilters = () => { + if (!onFiltersChange) return + onFiltersChange({ + nameContains: null, + scoreMin: null, + scoreMax: null, + winrateMin: null, + winrateMax: null, + maxDrawdownMax: null, + tickers: null, + indicators: null, + durationMinDays: null, + durationMaxDays: null, + }) + // Reset local state + setNameContains('') + setScoreMin(0) + setScoreMax(100) + setWinMin(0) + setWinMax(100) + setMaxDrawdownMax('') + setTickersInput('') + setSelectedIndicators([]) + setDurationMinDays(null) + setDurationMaxDays(null) + } + + // Refresh data function + const refreshData = () => { + if (onBacktestDeleted) { + onBacktestDeleted() + } + } + const applyFilters = () => { if (!onFiltersChange) return onFiltersChange({ @@ -299,6 +335,13 @@ const BacktestTable: React.FC = ({list, isFetching, onSortCh setDurationMaxDays(filters.durationMaxDays ?? null) }, [filters]) + // Handle external trigger to open filters + useEffect(() => { + if (openFiltersTrigger && openFiltersTrigger > 0) { + setIsFilterOpen(true) + } + }, [openFiltersTrigger]) + // Handle sort change const handleSortChange = (columnId: string, sortOrder: 'asc' | 'desc') => { if (!onSortChange) return; diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index 049b9eee..a3281327 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -1072,6 +1072,44 @@ export class BacktestClient extends AuthorizedApiBase { return Promise.resolve(null as any); } + backtest_GetBundleStatus(bundleRequestId: string): Promise { + let url_ = this.baseUrl + "/Backtest/Bundle/{bundleRequestId}/Status"; + if (bundleRequestId === undefined || bundleRequestId === null) + throw new Error("The parameter 'bundleRequestId' must be defined."); + url_ = url_.replace("{bundleRequestId}", encodeURIComponent("" + bundleRequestId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processBacktest_GetBundleStatus(_response); + }); + } + + protected processBacktest_GetBundleStatus(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BundleBacktestStatusResponse; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + backtest_RunGenetic(request: RunGeneticRequest): Promise { let url_ = this.baseUrl + "/Backtest/Genetic"; url_ = url_.replace(/[?&]$/, ""); @@ -2354,6 +2392,152 @@ export class DataClient extends AuthorizedApiBase { } } +export class JobClient extends AuthorizedApiBase { + private http: { fetch(url: RequestInfo, init?: RequestInit): Promise }; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(configuration: IConfig, baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) { + super(configuration); + this.http = http ? http : window as any; + this.baseUrl = baseUrl ?? "http://localhost:5000"; + } + + job_GetJobStatus(jobId: string): Promise { + let url_ = this.baseUrl + "/Job/{jobId}"; + if (jobId === undefined || jobId === null) + throw new Error("The parameter 'jobId' must be defined."); + url_ = url_.replace("{jobId}", encodeURIComponent("" + jobId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processJob_GetJobStatus(_response); + }); + } + + protected processJob_GetJobStatus(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BacktestJobStatusResponse; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + job_GetJobs(page: number | undefined, pageSize: number | undefined, sortBy: string | undefined, sortOrder: string | undefined, status: string | null | undefined, jobType: string | null | undefined, userId: number | null | undefined, workerId: string | null | undefined, bundleRequestId: string | null | undefined): Promise { + let url_ = this.baseUrl + "/Job?"; + if (page === null) + throw new Error("The parameter 'page' cannot be null."); + else if (page !== undefined) + url_ += "page=" + encodeURIComponent("" + page) + "&"; + if (pageSize === null) + throw new Error("The parameter 'pageSize' cannot be null."); + else if (pageSize !== undefined) + url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&"; + if (sortBy === null) + throw new Error("The parameter 'sortBy' cannot be null."); + else if (sortBy !== undefined) + url_ += "sortBy=" + encodeURIComponent("" + sortBy) + "&"; + if (sortOrder === null) + throw new Error("The parameter 'sortOrder' cannot be null."); + else if (sortOrder !== undefined) + url_ += "sortOrder=" + encodeURIComponent("" + sortOrder) + "&"; + if (status !== undefined && status !== null) + url_ += "status=" + encodeURIComponent("" + status) + "&"; + if (jobType !== undefined && jobType !== null) + url_ += "jobType=" + encodeURIComponent("" + jobType) + "&"; + if (userId !== undefined && userId !== null) + url_ += "userId=" + encodeURIComponent("" + userId) + "&"; + if (workerId !== undefined && workerId !== null) + url_ += "workerId=" + encodeURIComponent("" + workerId) + "&"; + if (bundleRequestId !== undefined && bundleRequestId !== null) + url_ += "bundleRequestId=" + encodeURIComponent("" + bundleRequestId) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processJob_GetJobs(_response); + }); + } + + protected processJob_GetJobs(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PaginatedJobsResponse; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + job_GetJobSummary(): Promise { + let url_ = this.baseUrl + "/Job/summary"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processJob_GetJobSummary(_response); + }); + } + + protected processJob_GetJobSummary(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as JobSummaryResponse; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } +} + export class MoneyManagementClient extends AuthorizedApiBase { private http: { fetch(url: RequestInfo, init?: RequestInit): Promise }; private baseUrl: string; @@ -4740,6 +4924,20 @@ export interface BundleBacktestRequestViewModel { estimatedTimeRemainingSeconds?: number | null; } +export interface BundleBacktestStatusResponse { + bundleRequestId?: string; + status?: string | null; + totalJobs?: number; + completedJobs?: number; + failedJobs?: number; + runningJobs?: number; + pendingJobs?: number; + progressPercentage?: number; + createdAt?: Date; + completedAt?: Date | null; + errorMessage?: string | null; +} + export interface GeneticRequest { requestId: string; user: User; @@ -5199,6 +5397,69 @@ export interface AgentBalance { time?: Date; } +export interface BacktestJobStatusResponse { + jobId?: string; + status?: string | null; + progressPercentage?: number; + createdAt?: Date; + startedAt?: Date | null; + completedAt?: Date | null; + errorMessage?: string | null; + result?: LightBacktest | null; +} + +export interface PaginatedJobsResponse { + jobs?: JobListItemResponse[]; + totalCount?: number; + currentPage?: number; + pageSize?: number; + totalPages?: number; + hasNextPage?: boolean; + hasPreviousPage?: boolean; +} + +export interface JobListItemResponse { + jobId?: string; + status?: string; + jobType?: string; + progressPercentage?: number; + priority?: number; + userId?: number; + bundleRequestId?: string | null; + geneticRequestId?: string | null; + assignedWorkerId?: string | null; + createdAt?: Date; + startedAt?: Date | null; + completedAt?: Date | null; + lastHeartbeat?: Date | null; + errorMessage?: string | null; + startDate?: Date; + endDate?: Date; +} + +export interface JobSummaryResponse { + statusSummary?: JobStatusSummary[]; + jobTypeSummary?: JobTypeSummary[]; + statusTypeSummary?: JobStatusTypeSummary[]; + totalJobs?: number; +} + +export interface JobStatusSummary { + status?: string; + count?: number; +} + +export interface JobTypeSummary { + jobType?: string; + count?: number; +} + +export interface JobStatusTypeSummary { + status?: string; + jobType?: string; + count?: number; +} + export interface ScenarioViewModel { name: string; indicators: IndicatorViewModel[]; diff --git a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts index 0aed75c4..5b82926c 100644 --- a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts +++ b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts @@ -735,6 +735,20 @@ export interface BundleBacktestRequestViewModel { estimatedTimeRemainingSeconds?: number | null; } +export interface BundleBacktestStatusResponse { + bundleRequestId?: string; + status?: string | null; + totalJobs?: number; + completedJobs?: number; + failedJobs?: number; + runningJobs?: number; + pendingJobs?: number; + progressPercentage?: number; + createdAt?: Date; + completedAt?: Date | null; + errorMessage?: string | null; +} + export interface GeneticRequest { requestId: string; user: User; @@ -1194,6 +1208,69 @@ export interface AgentBalance { time?: Date; } +export interface BacktestJobStatusResponse { + jobId?: string; + status?: string | null; + progressPercentage?: number; + createdAt?: Date; + startedAt?: Date | null; + completedAt?: Date | null; + errorMessage?: string | null; + result?: LightBacktest | null; +} + +export interface PaginatedJobsResponse { + jobs?: JobListItemResponse[]; + totalCount?: number; + currentPage?: number; + pageSize?: number; + totalPages?: number; + hasNextPage?: boolean; + hasPreviousPage?: boolean; +} + +export interface JobListItemResponse { + jobId?: string; + status?: string; + jobType?: string; + progressPercentage?: number; + priority?: number; + userId?: number; + bundleRequestId?: string | null; + geneticRequestId?: string | null; + assignedWorkerId?: string | null; + createdAt?: Date; + startedAt?: Date | null; + completedAt?: Date | null; + lastHeartbeat?: Date | null; + errorMessage?: string | null; + startDate?: Date; + endDate?: Date; +} + +export interface JobSummaryResponse { + statusSummary?: JobStatusSummary[]; + jobTypeSummary?: JobTypeSummary[]; + statusTypeSummary?: JobStatusTypeSummary[]; + totalJobs?: number; +} + +export interface JobStatusSummary { + status?: string; + count?: number; +} + +export interface JobTypeSummary { + jobType?: string; + count?: number; +} + +export interface JobStatusTypeSummary { + status?: string; + jobType?: string; + count?: number; +} + export interface ScenarioViewModel { name: string; indicators: IndicatorViewModel[]; diff --git a/src/Managing.WebApp/src/pages/adminPage/admin.tsx b/src/Managing.WebApp/src/pages/adminPage/admin.tsx index 0faf446f..077e2bf2 100644 --- a/src/Managing.WebApp/src/pages/adminPage/admin.tsx +++ b/src/Managing.WebApp/src/pages/adminPage/admin.tsx @@ -4,6 +4,7 @@ import {Tabs} from '../../components/mollecules' import AccountSettings from './account/accountSettings' import WhitelistSettings from './whitelist/whitelistSettings' +import JobsSettings from './jobs/jobsSettings' type TabsType = { label: string @@ -23,6 +24,11 @@ const tabs: TabsType = [ index: 2, label: 'Account', }, + { + Component: JobsSettings, + index: 3, + label: 'Jobs', + }, ] const Admin: React.FC = () => { diff --git a/src/Managing.WebApp/src/pages/adminPage/jobs/jobsSettings.tsx b/src/Managing.WebApp/src/pages/adminPage/jobs/jobsSettings.tsx new file mode 100644 index 00000000..7e68dbd3 --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/jobs/jobsSettings.tsx @@ -0,0 +1,510 @@ +import {useState} from 'react' +import {useQuery} from '@tanstack/react-query' + +import useApiUrlStore from '../../../app/store/apiStore' +import {JobClient} from '../../../generated/ManagingApi' +import {BottomMenuBar} from '../../../components/mollecules' + +import JobsTable from './jobsTable' + +const JobsSettings: React.FC = () => { + const { apiUrl } = useApiUrlStore() + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(50) + const [sortBy, setSortBy] = useState('CreatedAt') + const [sortOrder, setSortOrder] = useState('desc') + const [statusFilter, setStatusFilter] = useState('Pending') + const [jobTypeFilter, setJobTypeFilter] = useState('') + const [userIdFilter, setUserIdFilter] = useState('') + const [workerIdFilter, setWorkerIdFilter] = useState('') + const [bundleRequestIdFilter, setBundleRequestIdFilter] = useState('') + const [filtersOpen, setFiltersOpen] = useState(false) + + const jobClient = new JobClient({}, apiUrl) + + // Fetch job summary statistics + const { + data: jobSummary, + isLoading: isLoadingSummary + } = useQuery({ + queryKey: ['jobSummary'], + queryFn: async () => { + return await jobClient.job_GetJobSummary() + }, + staleTime: 10000, // 10 seconds + gcTime: 5 * 60 * 1000, + refetchInterval: 5000, // Auto-refresh every 5 seconds + }) + + const { + data: jobsData, + isLoading, + error, + refetch + } = useQuery({ + queryKey: ['jobs', page, pageSize, sortBy, sortOrder, statusFilter, jobTypeFilter, userIdFilter, workerIdFilter, bundleRequestIdFilter], + queryFn: async () => { + return await jobClient.job_GetJobs( + page, + pageSize, + sortBy, + sortOrder, + statusFilter || null, + jobTypeFilter || null, + userIdFilter ? parseInt(userIdFilter) : null, + workerIdFilter || null, + bundleRequestIdFilter || null + ) + }, + staleTime: 10000, // 10 seconds + gcTime: 5 * 60 * 1000, + refetchInterval: 5000, // Auto-refresh every 5 seconds + }) + + const jobs = jobsData?.jobs || [] + const totalCount = jobsData?.totalCount || 0 + const totalPages = jobsData?.totalPages || 0 + const currentPage = jobsData?.currentPage || 1 + + const handlePageChange = (newPage: number) => { + setPage(newPage) + } + + const handleSortChange = (newSortBy: string) => { + if (sortBy === newSortBy) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') + } else { + setSortBy(newSortBy) + setSortOrder('desc') + } + } + + const handleFilterChange = () => { + setPage(1) // Reset to first page when filters change + } + + const clearFilters = () => { + setStatusFilter('Pending') // Reset to Pending instead of All + setJobTypeFilter('') + setUserIdFilter('') + setWorkerIdFilter('') + setBundleRequestIdFilter('') + setPage(1) + } + + // Helper function to get status badge color + const getStatusBadgeColor = (status: string | undefined) => { + if (!status) return 'badge-ghost' + const statusLower = status.toLowerCase() + switch (statusLower) { + case 'pending': + return 'badge-warning' + case 'running': + return 'badge-info' + case 'completed': + return 'badge-success' + case 'failed': + return 'badge-error' + case 'cancelled': + return 'badge-ghost' + default: + return 'badge-ghost' + } + } + + return ( +
+ {/* Job Summary Statistics */} +
+ {isLoadingSummary ? ( +
+ +

Loading job summary...

+
+ ) : jobSummary && ( +
+ {/* Status Overview Section */} + {jobSummary.statusSummary && jobSummary.statusSummary.length > 0 && ( +
+
+

+ + + + Status Overview +

+
+ {jobSummary.statusSummary.map((statusItem) => { + const statusLower = (statusItem.status || '').toLowerCase() + let statusIcon, statusDesc, statusColor + + switch (statusLower) { + case 'pending': + statusIcon = ( + + + + ) + statusDesc = 'Waiting to be processed' + statusColor = 'text-warning' + break + case 'running': + statusIcon = ( + + + + ) + statusDesc = 'Currently processing' + statusColor = 'text-info' + break + case 'completed': + statusIcon = ( + + + + ) + statusDesc = 'Successfully finished' + statusColor = 'text-success' + break + case 'failed': + statusIcon = ( + + + + ) + statusDesc = 'Requires attention' + statusColor = 'text-error' + break + case 'cancelled': + statusIcon = ( + + + + ) + statusDesc = 'Cancelled by user' + statusColor = 'text-neutral' + break + default: + statusIcon = ( + + + + ) + statusDesc = 'Unknown status' + statusColor = 'text-base-content' + } + + return ( +
+
+
+
+ {statusIcon} +
+
{statusItem.status || 'Unknown'}
+
{statusItem.count || 0}
+
{statusDesc}
+
+
+
+ ) + })} +
+
+
+ )} + + {/* Job Types Section */} + {jobSummary.jobTypeSummary && jobSummary.jobTypeSummary.length > 0 && ( +
+
+

+ + + + Job Types +

+
+ {jobSummary.jobTypeSummary.map((typeItem) => { + const jobTypeLower = (typeItem.jobType || '').toLowerCase() + let jobTypeIcon, jobTypeDesc + + switch (jobTypeLower) { + case 'backtest': + jobTypeIcon = ( + + + + ) + jobTypeDesc = 'Backtest jobs' + break + case 'geneticbacktest': + jobTypeIcon = ( + + + + ) + jobTypeDesc = 'Genetic backtest jobs' + break + default: + jobTypeIcon = ( + + + + ) + jobTypeDesc = 'Job type' + } + + return ( +
+
+
+
+ {jobTypeIcon} +
+
{typeItem.jobType || 'Unknown'}
+
{typeItem.count || 0}
+
{jobTypeDesc}
+
+
+
+ ) + })} +
+
+
+ )} + + {/* Status by Job Type Table Section */} + {jobSummary.statusTypeSummary && jobSummary.statusTypeSummary.length > 0 && ( +
+
+

+ + + + Status by Job Type +

+
+ + + + + + + + + + {jobSummary.statusTypeSummary.map((item, index) => ( + + + + + + ))} + +
StatusJob TypeCount
+ + {item.status || 'Unknown'} + + {item.jobType || 'Unknown'}{item.count || 0}
+
+
+
+ )} +
+ )} +
+ + {filtersOpen && ( +
+
+
+
+ + +
+ +
+ + +
+ +
+ + { + setUserIdFilter(e.target.value) + handleFilterChange() + }} + /> +
+ +
+ + { + setWorkerIdFilter(e.target.value) + handleFilterChange() + }} + /> +
+ +
+ + { + setBundleRequestIdFilter(e.target.value) + handleFilterChange() + }} + /> +
+
+
+
+ )} + + {error && ( +
+ Error loading jobs: {(error as any)?.message || 'Unknown error'} +
+ )} + + + + {/* Bottom Menu Bar */} + +
  • + { + e.preventDefault() + setFiltersOpen(!filtersOpen) + }} + className={filtersOpen ? 'active' : ''} + > + + + + +
  • +
  • + { + e.preventDefault() + refetch() + }} + > + + + + +
  • +
  • + { + e.preventDefault() + clearFilters() + }} + > + + + + +
  • +
    +
    + ) +} + +export default JobsSettings + diff --git a/src/Managing.WebApp/src/pages/adminPage/jobs/jobsTable.tsx b/src/Managing.WebApp/src/pages/adminPage/jobs/jobsTable.tsx new file mode 100644 index 00000000..1680388d --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/jobs/jobsTable.tsx @@ -0,0 +1,313 @@ +import React, {useMemo} from 'react' +import {type JobListItemResponse} from '../../../generated/ManagingApi' +import {Table} from '../../../components/mollecules' + +interface IJobsTable { + jobs: JobListItemResponse[] + isLoading: boolean + totalCount: number + currentPage: number + totalPages: number + pageSize: number + sortBy: string + sortOrder: string + onPageChange: (page: number) => void + onSortChange: (sortBy: string) => void +} + +const JobsTable: React.FC = ({ + jobs, + isLoading, + totalCount, + currentPage, + totalPages, + pageSize, + sortBy, + sortOrder, + onPageChange, + onSortChange +}) => { + const getStatusBadge = (status: string | null | undefined) => { + if (!status) return - + + const statusLower = status.toLowerCase() + switch (statusLower) { + case 'pending': + return Pending + case 'running': + return Running + case 'completed': + return Completed + case 'failed': + return Failed + case 'cancelled': + return Cancelled + default: + return {status} + } + } + + const getJobTypeBadge = (jobType: string | null | undefined) => { + if (!jobType) return - + + const typeLower = jobType.toLowerCase() + switch (typeLower) { + case 'backtest': + return Backtest + case 'geneticbacktest': + return Genetic + default: + return {jobType} + } + } + + const formatDate = (date: Date | string | null | undefined) => { + if (!date) return '-' + try { + return new Date(date).toLocaleString() + } catch { + return '-' + } + } + + const SortableHeader = ({ column, label }: { column: string; label: string }) => { + const isActive = sortBy === column + return ( +
    onSortChange(column)} + > + {label} + {isActive && ( + + {sortOrder === 'asc' ? '↑' : '↓'} + + )} +
    + ) + } + + const columns = useMemo(() => [ + { + Header: () => , + accessor: 'jobId', + width: 200, + Cell: ({ value }: any) => ( + {value || '-'} + ), + }, + { + Header: () => , + accessor: 'status', + width: 120, + Cell: ({ value }: any) => getStatusBadge(value), + }, + { + Header: () => , + accessor: 'jobType', + width: 120, + Cell: ({ value }: any) => getJobTypeBadge(value), + }, + { + Header: () => , + accessor: 'priority', + width: 100, + Cell: ({ value }: any) => ( + {value ?? 0} + ), + }, + { + Header: () => , + accessor: 'progressPercentage', + width: 150, + Cell: ({ value }: any) => { + const percentage = value ?? 0 + return ( +
    + + {percentage}% +
    + ) + }, + }, + { + Header: 'User ID', + accessor: 'userId', + width: 100, + }, + { + Header: 'Worker ID', + accessor: 'assignedWorkerId', + width: 150, + Cell: ({ value }: any) => ( + {value || '-'} + ), + }, + { + Header: 'Bundle Request ID', + accessor: 'bundleRequestId', + width: 200, + Cell: ({ value }: any) => ( + {value || '-'} + ), + }, + { + Header: 'Genetic Request ID', + accessor: 'geneticRequestId', + width: 200, + Cell: ({ value }: any) => ( + {value || '-'} + ), + }, + { + Header: () => , + accessor: 'createdAt', + width: 180, + Cell: ({ value }: any) => formatDate(value), + }, + { + Header: () => , + accessor: 'startedAt', + width: 180, + Cell: ({ value }: any) => formatDate(value), + }, + { + Header: () => , + accessor: 'completedAt', + width: 180, + Cell: ({ value }: any) => formatDate(value), + }, + { + Header: 'Error Message', + accessor: 'errorMessage', + width: 300, + Cell: ({ value }: any) => ( + {value || '-'} + ), + }, + ], [sortBy, sortOrder, onSortChange]) + + const tableData = useMemo(() => { + return jobs.map((job) => ({ + jobId: job.jobId, + status: job.status, + jobType: job.jobType, + priority: job.priority, + progressPercentage: job.progressPercentage, + userId: job.userId, + assignedWorkerId: job.assignedWorkerId, + bundleRequestId: job.bundleRequestId, + geneticRequestId: job.geneticRequestId, + createdAt: job.createdAt, + startedAt: job.startedAt, + completedAt: job.completedAt, + errorMessage: job.errorMessage, + startDate: job.startDate, + endDate: job.endDate, + lastHeartbeat: job.lastHeartbeat, + })) + }, [jobs]) + + return ( +
    +
    +

    + Total jobs: {totalCount} | Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} | Page {currentPage} of {totalPages} +

    +
    + + {isLoading && ( +
    + +
    + )} + + {!isLoading && jobs.length === 0 && ( +
    + No jobs found. +
    + )} + + {!isLoading && jobs.length > 0 && ( + <> +
    + + + + {/* Manual Pagination */} + {totalPages > 1 && ( +
    + + + + {/* Page numbers */} +
    + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum + if (totalPages <= 5) { + pageNum = i + 1 + } else if (currentPage <= 3) { + pageNum = i + 1 + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i + } else { + pageNum = currentPage - 2 + i + } + + return ( + + ) + })} +
    + + + +
    + )} + + )} + + ) +} + +export default JobsTable + diff --git a/src/Managing.WebApp/src/pages/authPage/auth.tsx b/src/Managing.WebApp/src/pages/authPage/auth.tsx index fcfa666f..c2db0b4f 100644 --- a/src/Managing.WebApp/src/pages/authPage/auth.tsx +++ b/src/Managing.WebApp/src/pages/authPage/auth.tsx @@ -7,6 +7,73 @@ import {useEffect, useState} from 'react' import {useAuthStore} from '../../app/store/accountStore' import {ALLOWED_TICKERS, useClaimUiFees, useClaimUiFeesTransaction} from '../../hooks/useClaimUiFees' import Toast from '../../components/mollecules/Toast/Toast' +import useApiUrlStore from '../../app/store/apiStore' + +// Environment Badge Component +const EnvironmentBadge = ({ environment }: { environment: 'local' | 'sandbox' | 'production' }) => { + const badgeColors = { + local: 'badge-warning', + sandbox: 'badge-info', + production: 'badge-success', + } + + const badgeLabels = { + local: 'Local', + sandbox: 'Sandbox', + production: 'Production', + } + + return ( +
    + {badgeLabels[environment]} +
    + ) +} + +// Environment Dropdown Component +const EnvironmentDropdown = () => { + const { environment, setEnvironment } = useApiUrlStore() + + const handleEnvironmentChange = (newEnv: 'local' | 'sandbox' | 'production') => { + setEnvironment(newEnv) + // Reload page to reinitialize Privy with new app ID + window.location.reload() + } + + return ( + + ) +} export const Auth = ({ children }: any) => { const { getCookie, deleteCookie } = useCookie() @@ -90,6 +157,9 @@ export const Auth = ({ children }: any) => { deleteCookie('token') return (
    +
    + +
    + + {/* Bundle Status Display */} + {bundleStatus && ( +
    +

    Job Progress

    +
    +
    +
    Total Jobs
    +
    {bundleStatus.totalJobs || 0}
    +
    +
    +
    Pending
    +
    {bundleStatus.pendingJobs || 0}
    +
    +
    +
    Running
    +
    {bundleStatus.runningJobs || 0}
    +
    +
    +
    Completed
    +
    {bundleStatus.completedJobs || 0}
    +
    +
    +
    Failed
    +
    {bundleStatus.failedJobs || 0}
    +
    +
    + {bundleStatus.progressPercentage !== undefined && ( +
    +
    + Progress + {bundleStatus.progressPercentage}% +
    + +
    + )} + {bundleStatus.errorMessage && ( +
    + Error: {bundleStatus.errorMessage} +
    + )} +
    + )} +
    Backtest Results
    {isLoading ? (
    Loading backtests...
    @@ -413,6 +478,56 @@ const BundleRequestModal: React.FC = ({ + + {/* Bottom Menu Bar */} + +
  • + +
  • +
  • + +
  • +
    ); } @@ -845,6 +960,86 @@ const BundleRequestModal: React.FC = ({ + + {/* Bottom Menu Bar */} + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    ); }; diff --git a/src/Managing.WebApp/src/pages/backtestPage/backtestScanner.tsx b/src/Managing.WebApp/src/pages/backtestPage/backtestScanner.tsx index 845be7e4..e1586e91 100644 --- a/src/Managing.WebApp/src/pages/backtestPage/backtestScanner.tsx +++ b/src/Managing.WebApp/src/pages/backtestPage/backtestScanner.tsx @@ -1,11 +1,10 @@ -import {ColorSwatchIcon, TrashIcon} from '@heroicons/react/solid' import {useQuery, useQueryClient} from '@tanstack/react-query' import React, {useEffect, useState} from 'react' import 'react-toastify/dist/ReactToastify.css' import useApiUrlStore from '../../app/store/apiStore' import {Loader, Slider} from '../../components/atoms' -import {Modal, Toast} from '../../components/mollecules' +import {BottomMenuBar, Modal, Toast} from '../../components/mollecules' import {BacktestTable, UnifiedTradingModal} from '../../components/organism' import type {LightBacktestResponse} from '../../generated/ManagingApi' import {BacktestClient, BacktestSortableColumn} from '../../generated/ManagingApi' @@ -25,6 +24,7 @@ const BacktestScanner: React.FC = () => { sortBy: BacktestSortableColumn.Score, sortOrder: 'desc' }) + const [openFiltersTrigger, setOpenFiltersTrigger] = useState(0) // Filters state coming from BacktestTable sidebar const [filters, setFilters] = useState<{ @@ -233,20 +233,7 @@ const BacktestScanner: React.FC = () => { } return ( -
    -
    - -
    -
    - -
    +
    {/* Selected filters summary */}
    @@ -302,6 +289,7 @@ const BacktestScanner: React.FC = () => { // Invalidate backtest queries when a backtest is deleted queryClient.invalidateQueries({ queryKey: ['backtests'] }) }} + openFiltersTrigger={openFiltersTrigger} /> {/* Pagination controls */} {totalPages > 1 && ( @@ -406,6 +394,107 @@ const BacktestScanner: React.FC = () => {
    + + {/* Bottom Menu Bar */} + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    ) } diff --git a/src/Managing.WebApp/src/pages/backtestPage/bundleRequestsTable.tsx b/src/Managing.WebApp/src/pages/backtestPage/bundleRequestsTable.tsx index afd4700b..958cf2d6 100644 --- a/src/Managing.WebApp/src/pages/backtestPage/bundleRequestsTable.tsx +++ b/src/Managing.WebApp/src/pages/backtestPage/bundleRequestsTable.tsx @@ -5,6 +5,7 @@ import Table from '../../components/mollecules/Table/Table'; import {BundleBacktestRequest} from '../../generated/ManagingApiTypes'; import Toast from '../../components/mollecules/Toast/Toast'; import BundleRequestModal from './BundleRequestModal'; +import {BottomMenuBar} from '../../components/mollecules'; const BundleRequestsTable = () => { const { apiUrl } = useApiUrlStore(); @@ -154,19 +155,7 @@ const BundleRequestsTable = () => { if (error) return
    {error}
    ; return ( -
    -
    -

    Bundle Backtest Requests

    - -
    +
    { fetchData(); // Refresh the table }} /> + + {/* Bottom Menu Bar */} + +
  • + +
  • +
  • + +
  • +
    ); }; diff --git a/src/Managing.WebApp/src/pages/settingsPage/UserInfoSettings.tsx b/src/Managing.WebApp/src/pages/settingsPage/UserInfoSettings.tsx index e19fce9d..a31d5d0c 100644 --- a/src/Managing.WebApp/src/pages/settingsPage/UserInfoSettings.tsx +++ b/src/Managing.WebApp/src/pages/settingsPage/UserInfoSettings.tsx @@ -115,66 +115,100 @@ function UserInfoSettings() { return (
    -

    User Information

    +

    User Information

    -
    -
    - -

    {user?.name}

    -
    - -
    - -

    {user?.agentName || 'Not set'}

    - -
    - -
    - -
    - {user?.avatarUrl ? ( - User avatar - ) : ( -
    - {user?.name?.[0]?.toUpperCase() || '?'} -
    - )} - +
    + {/* Name Card */} +
    +
    +
    +

    Name

    +
    +

    {user?.name || 'N/A'}

    -
    - -

    {user?.telegramChannel || 'Not set'}

    -
    - - {user?.telegramChannel && ( + {/* Agent Name Card */} +
    +
    +
    +

    Agent Name

    +
    +
    +

    + {user?.agentName || 'Not set'} +

    - )} +
    +
    +
    + + {/* Avatar Card */} +
    +
    +
    +

    Avatar

    +
    +
    + {user?.avatarUrl ? ( +
    +
    + User avatar +
    +
    + ) : ( +
    +
    + {user?.name?.[0]?.toUpperCase() || '?'} +
    +
    + )} + +
    +
    +
    + + {/* Telegram Channel Card */} +
    +
    +
    +

    Telegram Channel

    +
    +
    +

    + {user?.telegramChannel || 'Not set'} +

    +
    +
    + + {user?.telegramChannel && ( + + )} +
    diff --git a/src/Managing.WebApp/src/pages/settingsPage/settings.tsx b/src/Managing.WebApp/src/pages/settingsPage/settings.tsx index eceea309..8136f093 100644 --- a/src/Managing.WebApp/src/pages/settingsPage/settings.tsx +++ b/src/Managing.WebApp/src/pages/settingsPage/settings.tsx @@ -4,7 +4,6 @@ import {Tabs} from '../../components/mollecules' import MoneyManagementSettings from './moneymanagement/moneyManagement' import Theme from './theme' -import DefaultConfig from './defaultConfig/defaultConfig' import UserInfoSettings from './UserInfoSettings' import AccountFee from './accountFee/accountFee' @@ -36,11 +35,6 @@ const tabs: TabsType = [ index: 4, label: 'Theme', }, - { - Component: DefaultConfig, - index: 5, - label: 'Quick Start Config', - }, ] const Settings: React.FC = () => { diff --git a/src/Managing.Workers.Api/Dockerfile b/src/Managing.Workers.Api/Dockerfile new file mode 100644 index 00000000..48fe0d1c --- /dev/null +++ b/src/Managing.Workers.Api/Dockerfile @@ -0,0 +1,39 @@ +# Use the official Microsoft .NET runtime as the base image (no ASP.NET needed for console worker) +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base +WORKDIR /app + +# Use the official Microsoft .NET SDK image to build the code. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Copy project files for dependency restoration +COPY ["Managing.Workers.Api/Managing.Workers.Api.csproj", "Managing.Workers.Api/"] +COPY ["Managing.Bootstrap/Managing.Bootstrap.csproj", "Managing.Bootstrap/"] +COPY ["Managing.Application/Managing.Application.csproj", "Managing.Application/"] +COPY ["Managing.Application.Abstractions/Managing.Application.Abstractions.csproj", "Managing.Application.Abstractions/"] +COPY ["Managing.Common/Managing.Common.csproj", "Managing.Common/"] +COPY ["Managing.Core/Managing.Core.csproj", "Managing.Core/"] +COPY ["Managing.Domain/Managing.Domain.csproj", "Managing.Domain/"] +COPY ["Managing.Infrastructure.Database/Managing.Infrastructure.Databases.csproj", "Managing.Infrastructure.Database/"] +COPY ["Managing.Infrastructure.Exchanges/Managing.Infrastructure.Exchanges.csproj", "Managing.Infrastructure.Exchanges/"] +COPY ["Managing.Infrastructure.Messengers/Managing.Infrastructure.Messengers.csproj", "Managing.Infrastructure.Messengers/"] +COPY ["Managing.Infrastructure.Storage/Managing.Infrastructure.Storage.csproj", "Managing.Infrastructure.Storage/"] +COPY ["Managing.Infrastructure.Web3/Managing.Infrastructure.Evm.csproj", "Managing.Infrastructure.Web3/"] + +# Restore dependencies for all projects +RUN dotnet restore "Managing.Workers.Api/Managing.Workers.Api.csproj" + +# Copy everything else and build +COPY . . +WORKDIR "/src/Managing.Workers.Api" +RUN dotnet build "Managing.Workers.Api.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Managing.Workers.Api.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +ENTRYPOINT ["dotnet", "Managing.Workers.Api.dll"] + diff --git a/src/Managing.Workers.Api/Managing.Workers.Api.csproj b/src/Managing.Workers.Api/Managing.Workers.Api.csproj new file mode 100644 index 00000000..3539f4ae --- /dev/null +++ b/src/Managing.Workers.Api/Managing.Workers.Api.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + dotnet-Managing.Workers.Api-ff3f3987-4da4-4140-9180-b84c9e07b25f + + + + + + + + + + + + + + + + + + + diff --git a/src/Managing.Workers.Api/Program.cs b/src/Managing.Workers.Api/Program.cs new file mode 100644 index 00000000..26a8bda1 --- /dev/null +++ b/src/Managing.Workers.Api/Program.cs @@ -0,0 +1,119 @@ +using Managing.Application.Workers; +using Managing.Bootstrap; +using Managing.Common; +using Managing.Infrastructure.Databases.InfluxDb.Models; +using Managing.Infrastructure.Databases.PostgreSql; +using Managing.Infrastructure.Databases.PostgreSql.Configurations; +using Microsoft.EntityFrameworkCore; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((hostingContext, config) => + { + config.SetBasePath(AppContext.BaseDirectory); + config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{hostingContext.HostingEnvironment.EnvironmentName}.json", optional: true) + .AddEnvironmentVariables() + .AddUserSecrets(); + }) + .ConfigureServices((hostContext, services) => + { + var configuration = hostContext.Configuration; + + // Initialize Sentry + SentrySdk.Init(options => + { + options.Dsn = configuration["Sentry:Dsn"]; + options.Debug = false; + options.SendDefaultPii = true; + options.AutoSessionTracking = true; + options.IsGlobalModeEnabled = false; + options.TracesSampleRate = 0.1; + options.Environment = hostContext.HostingEnvironment.EnvironmentName; + }); + + // Configure database + var postgreSqlConnectionString = configuration.GetSection(Constants.Databases.PostgreSql)["ConnectionString"]; + + services.Configure(configuration.GetSection(Constants.Databases.PostgreSql)); + services.Configure(configuration.GetSection(Constants.Databases.InfluxDb)); + + // Add DbContext + services.AddDbContext((serviceProvider, options) => + { + options.UseNpgsql(postgreSqlConnectionString, npgsqlOptions => + { + npgsqlOptions.CommandTimeout(60); + npgsqlOptions.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorCodesToAdd: null); + }); + + if (hostContext.HostingEnvironment.IsDevelopment()) + { + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + } + + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + options.EnableServiceProviderCaching(); + }, ServiceLifetime.Scoped); + + // Register compute dependencies (no Orleans) + services.RegisterComputeDependencies(configuration); + + // Configure BacktestComputeWorker options + services.Configure( + configuration.GetSection(BacktestComputeWorkerOptions.SectionName)); + + // Override WorkerId from environment variable if provided + var workerId = Environment.GetEnvironmentVariable("WORKER_ID") ?? + configuration["BacktestComputeWorker:WorkerId"] ?? + Environment.MachineName; + services.Configure(options => + { + options.WorkerId = workerId; + }); + + // Register the compute worker if enabled + var isWorkerEnabled = configuration.GetValue("WorkerBacktestCompute", false); + if (isWorkerEnabled) + { + services.AddHostedService(); + } + }) + .ConfigureLogging((hostingContext, logging) => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging")); + }) + .Build(); + +// Log worker status +var logger = host.Services.GetRequiredService>(); +var isWorkerEnabled = host.Services.GetRequiredService().GetValue("WorkerBacktestCompute", false); + +if (isWorkerEnabled) +{ + logger.LogInformation("BacktestComputeWorker is enabled and will be started."); + logger.LogInformation("Worker ID: {WorkerId}", Environment.GetEnvironmentVariable("WORKER_ID") ?? + host.Services.GetRequiredService()["BacktestComputeWorker:WorkerId"] ?? + Environment.MachineName); +} +else +{ + logger.LogWarning("BacktestComputeWorker is disabled via configuration. No jobs will be processed."); +} + +try +{ + await host.RunAsync(); +} +catch (Exception ex) +{ + logger.LogCritical(ex, "Application terminated unexpectedly"); + SentrySdk.CaptureException(ex); + throw; +} +finally +{ + SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)).Wait(); +} diff --git a/src/Managing.Workers.Api/Worker.cs b/src/Managing.Workers.Api/Worker.cs new file mode 100644 index 00000000..bd089848 --- /dev/null +++ b/src/Managing.Workers.Api/Worker.cs @@ -0,0 +1,24 @@ +namespace Managing.Workers.Api; + +public class Worker : BackgroundService +{ + private readonly ILogger _logger; + + public Worker(ILogger logger) + { + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); + } + + await Task.Delay(1000, stoppingToken); + } + } +} \ No newline at end of file diff --git a/src/Managing.Workers.Api/appsettings.Development.json b/src/Managing.Workers.Api/appsettings.Development.json new file mode 100644 index 00000000..066cc5e6 --- /dev/null +++ b/src/Managing.Workers.Api/appsettings.Development.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "WorkerBacktestCompute": true, + "BacktestComputeWorker": { + "WorkerId": "local-worker-1", + "MaxConcurrentBacktests": 6, + "JobPollIntervalSeconds": 5, + "HeartbeatIntervalSeconds": 30, + "StaleJobTimeoutMinutes": 5 + }, + "PostgreSql": { + "ConnectionString": "Host=localhost;Port=5432;Database=managing;Username=postgres;Password=postgres" + }, + "InfluxDb": { + "Url": "http://localhost:8086/", + "Organization": "managing-org", + "Token": "Fw2FPL2OwTzDHzSbR2Sd5xs0EKQYy00Q-hYKYAhr9cC1_q5YySONpxuf_Ck0PTjyUiF13xXmi__bu_pXH-H9zA==" + }, + "Sentry": { + "Dsn": "" + } +} diff --git a/src/Managing.Workers.Api/appsettings.json b/src/Managing.Workers.Api/appsettings.json new file mode 100644 index 00000000..796fe915 --- /dev/null +++ b/src/Managing.Workers.Api/appsettings.json @@ -0,0 +1,38 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "WorkerBacktestCompute": true, + "BacktestComputeWorker": { + "WorkerId": "worker-1", + "MaxConcurrentBacktests": 6, + "JobPollIntervalSeconds": 5, + "HeartbeatIntervalSeconds": 30, + "StaleJobTimeoutMinutes": 5 + }, + "PostgreSql": { + "ConnectionString": "Host=managing-postgre.apps.managing.live;Port=5432;Database=managing;Username=postgres;Password=29032b13a5bc4d37" + }, + "InfluxDb": { + "Url": "https://influx-db.apps.managing.live", + "Organization": "managing-org", + "Token": "eOuXcXhH7CS13Iw4CTiDDpRjIjQtEVPOloD82pLPOejI4n0BsEj1YzUw0g3Cs1mdDG5m-RaxCavCMsVTtS5wIQ==" + }, + "Sentry": { + "Dsn": "https://fe12add48c56419bbdfa86227c188e7a@glitch.kai.managing.live/1" + }, + "N8n": { + "WebhookUrl": "https://n8n.kai.managing.live/webhook/fa9308b6-983b-42ec-b085-71599d655951", + "IndicatorRequestWebhookUrl": "https://n8n.kai.managing.live/webhook/3aa07b66-1e64-46a7-8618-af300914cb11", + "Username": "managing-api", + "Password": "T259836*PdiV2@%!eR%Qf4" + }, + "Kaigen": { + "BaseUrl": "https://kaigen-back-kaigen-stage.up.railway.app", + "DebitEndpoint": "/api/credits/debit", + "RefundEndpoint": "/api/credits/refund" + } +} diff --git a/src/Managing.Workers.Api/captain-definition b/src/Managing.Workers.Api/captain-definition new file mode 100644 index 00000000..cef6a2d5 --- /dev/null +++ b/src/Managing.Workers.Api/captain-definition @@ -0,0 +1,5 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "../Dockerfile-worker-api-dev" +} + diff --git a/src/Managing.sln b/src/Managing.sln index 36c02a81..a1b74b25 100644 --- a/src/Managing.sln +++ b/src/Managing.sln @@ -68,6 +68,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Managing.Aspire.ServiceDefa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Managing.Nswag", "Managing.Nswag\Managing.Nswag.csproj", "{BE50F950-C1D4-4CE0-B32E-6AAC996770D5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Managing.Workers.Api", "Managing.Workers.Api\Managing.Workers.Api.csproj", "{B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -236,6 +238,14 @@ Global {BE50F950-C1D4-4CE0-B32E-6AAC996770D5}.Release|Any CPU.Build.0 = Release|Any CPU {BE50F950-C1D4-4CE0-B32E-6AAC996770D5}.Release|x64.ActiveCfg = Release|Any CPU {BE50F950-C1D4-4CE0-B32E-6AAC996770D5}.Release|x64.Build.0 = Release|Any CPU + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}.Debug|x64.Build.0 = Debug|Any CPU + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}.Release|Any CPU.Build.0 = Release|Any CPU + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}.Release|x64.ActiveCfg = Release|Any CPU + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -258,6 +268,7 @@ Global {A1D88DC3-1CF6-4C03-AEEC-30AA37420CE1} = {D6711C71-A263-4398-8DFF-28E2CD1FE0CE} {4521E1A9-AF81-4CA8-8B4D-30C261ECE977} = {D6711C71-A263-4398-8DFF-28E2CD1FE0CE} {BE50F950-C1D4-4CE0-B32E-6AAC996770D5} = {D6711C71-A263-4398-8DFF-28E2CD1FE0CE} + {B7D66A73-CA3A-4DE5-8E88-59D50C4018A6} = {A1296069-2816-43D4-882C-516BCB718D03} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BD7CA081-CE52-4824-9777-C0562E54F3EA}