Add jobs
This commit is contained in:
243
.cursor/commands/build-solution.md
Normal file
243
.cursor/commands/build-solution.md
Normal file
@@ -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
|
||||||
|
|
||||||
299
.cursor/commands/implement-api-changes.md
Normal file
299
.cursor/commands/implement-api-changes.md
Normal file
@@ -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`
|
||||||
|
|
||||||
265
.cursor/commands/migration-local.md
Normal file
265
.cursor/commands/migration-local.md
Normal file
@@ -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 "<migration-name>" --startup-project ../Managing.Api`
|
||||||
|
|
||||||
|
**If migration creation succeeds:**
|
||||||
|
- Show: "✅ Migration created successfully: <migration-name>"
|
||||||
|
- 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/<timestamp>_<migration-name>.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: <migration-name>"
|
||||||
|
- 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`
|
||||||
|
|
||||||
95
.cursor/commands/migration-production.md
Normal file
95
.cursor/commands/migration-production.md
Normal file
@@ -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
|
||||||
|
|
||||||
76
.cursor/commands/migration-sandbox.md
Normal file
76
.cursor/commands/migration-sandbox.md
Normal file
@@ -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
|
||||||
|
|
||||||
626
.cursor/commands/responsive.md
Normal file
626
.cursor/commands/responsive.md
Normal file
@@ -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
|
||||||
|
<div className="w-full p-4 md:p-6 lg:p-8">
|
||||||
|
// Mobile: full width, padding 4
|
||||||
|
// md+: padding 6
|
||||||
|
// lg+: padding 8
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2: Layout Patterns
|
||||||
|
|
||||||
|
**Grid Layouts:**
|
||||||
|
```tsx
|
||||||
|
// Single column mobile, multi-column desktop
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Cards */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Responsive grid with auto-fit
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flexbox Layouts:**
|
||||||
|
```tsx
|
||||||
|
// Stack on mobile, row on desktop
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
{/* Items */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Center on mobile, space-between on desktop
|
||||||
|
<div className="flex flex-col items-center md:flex-row md:justify-between">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container Patterns:**
|
||||||
|
```tsx
|
||||||
|
// Use layout utility class or custom container
|
||||||
|
<div className="layout">
|
||||||
|
{/* Content with responsive margins */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Or custom responsive container
|
||||||
|
<div className="w-full px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3: Navigation Patterns (DaisyUI Navbar)
|
||||||
|
|
||||||
|
**DaisyUI Navbar Pattern** (https://daisyui.com/components/navbar/):
|
||||||
|
```tsx
|
||||||
|
// DaisyUI navbar with responsive menu
|
||||||
|
<div className="navbar bg-base-300">
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<div className="navbar-start">
|
||||||
|
<button className="btn btn-ghost lg:hidden" onClick={toggleMenu}>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a className="btn btn-ghost text-xl">Logo</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop navigation */}
|
||||||
|
<div className="navbar-center hidden lg:flex">
|
||||||
|
<ul className="menu menu-horizontal px-1">
|
||||||
|
<li><a>Item 1</a></li>
|
||||||
|
<li><a>Item 2</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navbar end */}
|
||||||
|
<div className="navbar-end">
|
||||||
|
<button className="btn btn-primary">Action</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mobile drawer/sidebar (DaisyUI Drawer pattern)
|
||||||
|
<div className={`drawer lg:drawer-open`}>
|
||||||
|
<input id="drawer-toggle" type="checkbox" className="drawer-toggle" checked={isOpen} onChange={toggleMenu} />
|
||||||
|
<div className="drawer-side">
|
||||||
|
<label htmlFor="drawer-toggle" className="drawer-overlay"></label>
|
||||||
|
<ul className="menu p-4 w-80 min-h-full bg-base-200 text-base-content">
|
||||||
|
{/* Mobile menu items */}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4: Table Patterns (DaisyUI Table)
|
||||||
|
|
||||||
|
**DaisyUI Table Patterns** (https://daisyui.com/components/table/):
|
||||||
|
```tsx
|
||||||
|
// Option 1: Horizontal scroll on mobile (recommended)
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Header 1</th>
|
||||||
|
<th>Header 2</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{/* Table rows */}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Option 2: Responsive table size (mobile: table-xs, desktop: table)
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-xs md:table table-zebra w-full">
|
||||||
|
{/* Table content */}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Option 3: Card layout on mobile, table on desktop
|
||||||
|
<div className="block md:hidden space-y-4">
|
||||||
|
{/* DaisyUI cards for mobile */}
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Card content matching table data */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
{/* Table for desktop */}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.5: Form Patterns (DaisyUI Form)
|
||||||
|
|
||||||
|
**DaisyUI Form Patterns** (https://daisyui.com/components/form/):
|
||||||
|
```tsx
|
||||||
|
// DaisyUI form-control with responsive grid
|
||||||
|
<form className="w-full max-w-2xl mx-auto space-y-4">
|
||||||
|
{/* Stacked on mobile, side-by-side on desktop */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">First Name</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" className="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Last Name</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" className="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full width field */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Email</span>
|
||||||
|
</label>
|
||||||
|
<input type="email" className="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Responsive button */}
|
||||||
|
<div className="form-control mt-6">
|
||||||
|
<button className="btn btn-primary w-full md:w-auto">Submit</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.6: Typography Patterns
|
||||||
|
|
||||||
|
**Responsive Text:**
|
||||||
|
```tsx
|
||||||
|
// Smaller on mobile, larger on desktop
|
||||||
|
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold">
|
||||||
|
Title
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-sm md:text-base lg:text-lg">
|
||||||
|
Content
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.7: Spacing Patterns
|
||||||
|
|
||||||
|
**Responsive Spacing:**
|
||||||
|
```tsx
|
||||||
|
// Tighter on mobile, more spacious on desktop
|
||||||
|
<div className="p-4 md:p-6 lg:p-8">
|
||||||
|
{/* Content */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Responsive gaps
|
||||||
|
<div className="flex flex-col gap-2 md:gap-4 lg:gap-6">
|
||||||
|
{/* Items */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.8: Modal/Dialog Patterns (DaisyUI Modal)
|
||||||
|
|
||||||
|
**DaisyUI Modal Patterns** (https://daisyui.com/components/modal/):
|
||||||
|
```tsx
|
||||||
|
// Full screen on mobile, centered on desktop
|
||||||
|
<dialog className={`modal ${isOpen ? 'modal-open' : ''}`}>
|
||||||
|
<div className="modal-box w-full max-w-none md:max-w-2xl mx-auto">
|
||||||
|
<h3 className="font-bold text-lg">Modal Title</h3>
|
||||||
|
<p className="py-4">Modal content</p>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button className="btn" onClick={closeModal}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop" onClick={closeModal}>
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
// Responsive modal with different sizes
|
||||||
|
<dialog className={`modal ${isOpen ? 'modal-open' : ''}`}>
|
||||||
|
<div className="modal-box w-11/12 max-w-none md:max-w-lg lg:max-w-2xl">
|
||||||
|
{/* Modal content */}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.9: Chart/Visualization Patterns
|
||||||
|
|
||||||
|
**Responsive Charts:**
|
||||||
|
```tsx
|
||||||
|
// Responsive chart container
|
||||||
|
<div ref={containerRef} className="w-full h-auto">
|
||||||
|
<Chart
|
||||||
|
width={containerWidth}
|
||||||
|
height={containerWidth * (isMobile ? 0.8 : 0.6)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Or use aspect ratio
|
||||||
|
<div className="w-full aspect-[4/3] md:aspect-[16/9]">
|
||||||
|
<Chart />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<div className="navbar bg-base-300">
|
||||||
|
<div className="navbar-start">
|
||||||
|
<button className="btn btn-ghost lg:hidden">☰</button>
|
||||||
|
</div>
|
||||||
|
<div className="navbar-center hidden lg:flex">
|
||||||
|
{/* Desktop nav items */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Drawer** (https://daisyui.com/components/drawer/):
|
||||||
|
- Use `drawer` with `drawer-side` for mobile sidebar
|
||||||
|
- Toggle with `drawer-open` class
|
||||||
|
```tsx
|
||||||
|
<div className="drawer lg:drawer-open">
|
||||||
|
<input id="drawer-toggle" type="checkbox" className="drawer-toggle" />
|
||||||
|
<div className="drawer-side">
|
||||||
|
<label htmlFor="drawer-toggle" className="drawer-overlay"></label>
|
||||||
|
<ul className="menu p-4 w-80 min-h-full bg-base-200">
|
||||||
|
{/* Sidebar content */}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<div className="card bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body p-4 md:p-6">
|
||||||
|
<h2 className="card-title text-lg md:text-xl">Title</h2>
|
||||||
|
<p className="text-sm md:text-base">Content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Table** (https://daisyui.com/components/table/):
|
||||||
|
- Wrap in `overflow-x-auto` for mobile scroll
|
||||||
|
- Use `table-xs` for mobile, `table` for desktop
|
||||||
|
```tsx
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
{/* Table content */}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<dialog className={`modal ${isOpen ? 'modal-open' : ''}`}>
|
||||||
|
<div className="modal-box w-full max-w-none md:max-w-2xl">
|
||||||
|
{/* Modal content */}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Form** (https://daisyui.com/components/form/):
|
||||||
|
- Use `form-control` with responsive grid
|
||||||
|
- Inputs: `input input-bordered w-full`
|
||||||
|
```tsx
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Label</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" className="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Bottom Navigation** (https://daisyui.com/components/bottom-navigation/):
|
||||||
|
- Use `btm-nav` for mobile bottom navigation
|
||||||
|
```tsx
|
||||||
|
<div className="btm-nav lg:hidden fixed bottom-0">
|
||||||
|
<button className="active">Home</button>
|
||||||
|
<button>Settings</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Tabs** (https://daisyui.com/components/tabs/):
|
||||||
|
- Use `tabs` with responsive layout
|
||||||
|
- Mobile: `tabs tabs-boxed`, Desktop: `tabs tabs-lifted`
|
||||||
|
```tsx
|
||||||
|
<div className="tabs tabs-boxed md:tabs-lifted">
|
||||||
|
<a className="tab">Tab 1</a>
|
||||||
|
<a className="tab tab-active">Tab 2</a>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **Dropdown** (https://daisyui.com/components/dropdown/):
|
||||||
|
- Use `dropdown` with responsive positioning
|
||||||
|
```tsx
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<label tabIndex={0} className="btn btn-ghost">Menu</label>
|
||||||
|
<ul className="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
|
||||||
|
{/* Dropdown items */}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Stats** (https://daisyui.com/components/stats/):
|
||||||
|
- Use `stats` with responsive grid
|
||||||
|
```tsx
|
||||||
|
<div className="stats stats-vertical md:stats-horizontal shadow w-full">
|
||||||
|
<div className="stat">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Implement Beautiful Mobile UX
|
||||||
|
|
||||||
|
**Mobile UX Best Practices:**
|
||||||
|
|
||||||
|
1. **Touch Targets:**
|
||||||
|
- Minimum 44x44px touch targets
|
||||||
|
- Adequate spacing between interactive elements
|
||||||
|
```tsx
|
||||||
|
<button className="btn btn-primary min-h-[44px] min-w-[44px]">
|
||||||
|
Action
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<div className="btm-nav lg:hidden fixed bottom-0 z-50 bg-base-300">
|
||||||
|
<button className="active text-primary">
|
||||||
|
<svg>...</svg>
|
||||||
|
<span className="btm-nav-label">Home</span>
|
||||||
|
</button>
|
||||||
|
<button>
|
||||||
|
<svg>...</svg>
|
||||||
|
<span className="btm-nav-label">Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Sticky Headers:**
|
||||||
|
- Keep important actions accessible
|
||||||
|
```tsx
|
||||||
|
<div className="sticky top-0 z-50 bg-base-100">
|
||||||
|
{/* Header content */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Loading States** (DaisyUI Loading - https://daisyui.com/components/loading/):
|
||||||
|
- Use DaisyUI loading spinners appropriately sized for mobile
|
||||||
|
```tsx
|
||||||
|
<div className="flex justify-center items-center min-h-[200px]">
|
||||||
|
<span className="loading loading-spinner loading-sm md:loading-md lg:loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Or use loading text
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
<span className="text-sm md:text-base">Loading...</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-auto"
|
||||||
|
alt="..."
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Conditional Rendering:**
|
||||||
|
- Render mobile/desktop versions conditionally if needed
|
||||||
|
```tsx
|
||||||
|
{isMobile ? <MobileComponent /> : <DesktopComponent />}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Reduce Animations on Mobile:**
|
||||||
|
- Consider `prefers-reduced-motion`
|
||||||
|
```tsx
|
||||||
|
<div className="transition-transform motion-reduce:transition-none">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
@@ -11,61 +11,79 @@
|
|||||||
|
|
||||||
## Phase 2: Compute Worker Project
|
## Phase 2: Compute Worker Project
|
||||||
|
|
||||||
- [ ] Create `Managing.Compute` project (console app/worker service)
|
- [ ] Refactor `Managing.Workers.Api` project (or rename to `Managing.Compute`)
|
||||||
- [ ] Add project reference to shared projects (Application, Domain, Infrastructure)
|
- [ ] Remove Orleans dependencies completely
|
||||||
- [ ] Configure DI container (NO Orleans)
|
- [ ] Add project references to shared projects (Application, Domain, Infrastructure)
|
||||||
|
- [ ] Configure DI container with all required services (NO Orleans)
|
||||||
- [ ] Create `BacktestComputeWorker` background service
|
- [ ] Create `BacktestComputeWorker` background service
|
||||||
- [ ] Implement job polling logic (every 5 seconds)
|
- [ ] Implement job polling logic (every 5 seconds)
|
||||||
- [ ] Implement job claiming with PostgreSQL advisory locks
|
- [ ] Implement job claiming with PostgreSQL advisory locks
|
||||||
- [ ] Implement semaphore-based concurrency control
|
- [ ] Implement semaphore-based concurrency control
|
||||||
- [ ] Implement progress callback mechanism
|
- [ ] Implement progress callback mechanism
|
||||||
- [ ] Implement heartbeat mechanism (every 30 seconds)
|
- [ ] Implement heartbeat mechanism (every 30 seconds)
|
||||||
- [ ] Add configuration: `MaxConcurrentBacktests`, `JobPollIntervalSeconds`
|
- [ ] Add configuration: `MaxConcurrentBacktests`, `JobPollIntervalSeconds`, `WorkerId`
|
||||||
|
|
||||||
## Phase 3: API Server Updates
|
## Phase 3: API Server Updates
|
||||||
|
|
||||||
- [ ] Update `BacktestController` to create jobs instead of calling grains directly
|
- [ ] Update `BacktestController` to create jobs instead of calling grains directly
|
||||||
- [ ] Implement `CreateBundleBacktest` endpoint (returns immediately)
|
- [ ] 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
|
- [ ] 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`
|
- [ ] Create `BacktestExecutor.cs` service (new file)
|
||||||
- [ ] Make backtest logic Orleans-agnostic (can run in worker or grain)
|
- [ ] Extract backtest execution logic from `BacktestTradingBotGrain` to `BacktestExecutor`
|
||||||
- [ ] Add progress callback support to `RunBacktestAsync` method
|
- [ ] Make backtest logic Orleans-agnostic (no grain dependencies)
|
||||||
- [ ] Ensure candle loading works in both contexts
|
- [ ] 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
|
## 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 metrics: pending jobs, running jobs, completed/failed counts
|
||||||
- [ ] Add stale job detection (reclaim jobs from dead workers)
|
- [ ] Add stale job detection (reclaim jobs from dead workers, LastHeartbeat > 5 min)
|
||||||
- [ ] Add logging for job lifecycle events
|
- [ ] 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`
|
- [ ] Inject `IHubContext<BacktestHub>` into compute worker or executor
|
||||||
- [ ] Create deployment configuration for compute workers
|
- [ ] Send SignalR progress updates during job execution
|
||||||
- [ ] Configure environment variables for compute cluster
|
- [ ] Update `BacktestJob.ProgressPercentage` in database
|
||||||
- [ ] Set up monitoring dashboards (Prometheus/Grafana)
|
- [ ] Update `BundleBacktestRequest` progress when jobs complete
|
||||||
- [ ] Configure auto-scaling rules for compute workers
|
- [ ] Send completion notifications via SignalR and Telegram
|
||||||
|
|
||||||
## Phase 7: Testing & Validation
|
## Phase 7: Deployment
|
||||||
|
|
||||||
- [ ] Test single backtest job processing
|
- [ ] Create Dockerfile for `Managing.Compute` (or update existing)
|
||||||
- [ ] Test bundle backtest with multiple jobs
|
- [ ] Update `docker-compose.yml` to add compute worker service
|
||||||
- [ ] Test concurrent job processing (multiple workers)
|
- [ ] Configure environment variables: `MaxConcurrentBacktests`, `JobPollIntervalSeconds`, `WorkerId`
|
||||||
- [ ] Test job recovery after worker failure
|
- [ ] Set up health check configuration in Docker
|
||||||
- [ ] Test priority queue ordering
|
- [ ] Configure auto-scaling rules for compute workers (min: 1, max: 10)
|
||||||
- [ ] Load test with 1000+ concurrent users
|
|
||||||
|
|
||||||
## Phase 8: Migration Strategy
|
## Phase 9: Testing & Validation
|
||||||
|
|
||||||
- [ ] Keep Orleans grains as fallback during transition
|
- [ ] Unit tests: BacktestJobRepository (advisory locks, job claiming, stale detection)
|
||||||
- [ ] Feature flag to switch between Orleans and Compute workers
|
- [ ] Unit tests: BacktestExecutor (core logic, progress callbacks)
|
||||||
- [ ] Gradual migration: test with small percentage of traffic
|
- [ ] Integration tests: Single backtest job processing
|
||||||
- [ ] Monitor performance and error rates
|
- [ ] Integration tests: Bundle backtest with multiple jobs
|
||||||
- [ ] Full cutover once validated
|
- [ ] 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
# Use the official Microsoft ASP.NET Core runtime as the base image.
|
# Use the official Microsoft .NET runtime as the base image (no ASP.NET needed for console worker)
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 80
|
|
||||||
EXPOSE 443
|
|
||||||
|
|
||||||
# Use the official Microsoft .NET SDK image to build the code.
|
# Use the official Microsoft .NET SDK image to build the code.
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
WORKDIR /buildapp
|
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.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/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.Common/Managing.Common.csproj", "Managing.Common/"]
|
||||||
COPY ["/src/Managing.Core/Managing.Core.csproj", "Managing.Core/"]
|
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.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/"]
|
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 . .
|
COPY . .
|
||||||
WORKDIR "/buildapp/src/Managing.Api.Workers"
|
WORKDIR "/buildapp/src/Managing.Workers.Api"
|
||||||
RUN dotnet build "Managing.Api.Workers.csproj" -c Release -o /app/build
|
RUN dotnet build "Managing.Workers.Api.csproj" -c Release -o /app/build
|
||||||
|
|
||||||
FROM build AS publish
|
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
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
#COPY Managing.Api.Workers/managing_cert.pfx .
|
ENTRYPOINT ["dotnet", "Managing.Workers.Api.dll"]
|
||||||
#COPY /src/appsettings.dev.vm.json ./appsettings.json
|
|
||||||
ENTRYPOINT ["dotnet", "Managing.Api.Workers.dll"]
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Managing.Api.Models.Requests;
|
using Managing.Api.Models.Requests;
|
||||||
using Managing.Api.Models.Responses;
|
using Managing.Api.Models.Responses;
|
||||||
|
using Managing.Application.Abstractions.Repositories;
|
||||||
using Managing.Application.Abstractions.Services;
|
using Managing.Application.Abstractions.Services;
|
||||||
using Managing.Application.Abstractions.Shared;
|
using Managing.Application.Abstractions.Shared;
|
||||||
using Managing.Application.Hubs;
|
using Managing.Application.Hubs;
|
||||||
@@ -34,6 +35,7 @@ public class BacktestController : BaseController
|
|||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
private readonly IMoneyManagementService _moneyManagementService;
|
private readonly IMoneyManagementService _moneyManagementService;
|
||||||
private readonly IGeneticService _geneticService;
|
private readonly IGeneticService _geneticService;
|
||||||
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="BacktestController"/> class.
|
/// Initializes a new instance of the <see cref="BacktestController"/> class.
|
||||||
@@ -51,13 +53,15 @@ public class BacktestController : BaseController
|
|||||||
IAccountService accountService,
|
IAccountService accountService,
|
||||||
IMoneyManagementService moneyManagementService,
|
IMoneyManagementService moneyManagementService,
|
||||||
IGeneticService geneticService,
|
IGeneticService geneticService,
|
||||||
IUserService userService) : base(userService)
|
IUserService userService,
|
||||||
|
IServiceScopeFactory serviceScopeFactory) : base(userService)
|
||||||
{
|
{
|
||||||
_hubContext = hubContext;
|
_hubContext = hubContext;
|
||||||
_backtester = backtester;
|
_backtester = backtester;
|
||||||
_accountService = accountService;
|
_accountService = accountService;
|
||||||
_moneyManagementService = moneyManagementService;
|
_moneyManagementService = moneyManagementService;
|
||||||
_geneticService = geneticService;
|
_geneticService = geneticService;
|
||||||
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -788,6 +792,51 @@ public class BacktestController : BaseController
|
|||||||
return Ok(new { Unsubscribed = true, RequestId = requestId });
|
return Ok(new { Unsubscribed = true, RequestId = requestId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the status of a bundle backtest request, aggregating all job statuses.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="bundleRequestId">The bundle request ID</param>
|
||||||
|
/// <returns>The bundle status with aggregated job statistics</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[Route("Bundle/{bundleRequestId}/Status")]
|
||||||
|
public async Task<ActionResult<BundleBacktestStatusResponse>> 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<IBacktestJobRepository>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs a genetic algorithm optimization with the specified configuration.
|
/// Runs a genetic algorithm optimization with the specified configuration.
|
||||||
/// This endpoint saves the genetic request to the database and returns the request ID.
|
/// This endpoint saves the genetic request to the database and returns the request ID.
|
||||||
|
|||||||
288
src/Managing.Api/Controllers/JobController.cs
Normal file
288
src/Managing.Api/Controllers/JobController.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller for managing job operations.
|
||||||
|
/// Provides endpoints for querying job status and progress.
|
||||||
|
/// Requires admin authorization for access.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("[controller]")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public class JobController : BaseController
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
|
private readonly IAdminConfigurationService _adminService;
|
||||||
|
private readonly ILogger<JobController> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="JobController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userService">The service for user management.</param>
|
||||||
|
/// <param name="serviceScopeFactory">The service scope factory for creating scoped services.</param>
|
||||||
|
/// <param name="adminService">The admin configuration service for authorization checks.</param>
|
||||||
|
/// <param name="logger">The logger instance.</param>
|
||||||
|
public JobController(
|
||||||
|
IUserService userService,
|
||||||
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
|
IAdminConfigurationService adminService,
|
||||||
|
ILogger<JobController> logger) : base(userService)
|
||||||
|
{
|
||||||
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
|
_adminService = adminService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the current user is an admin
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the status of a job by its ID.
|
||||||
|
/// Admin only endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The job ID to query</param>
|
||||||
|
/// <returns>The job status and result if completed</returns>
|
||||||
|
[HttpGet("{jobId}")]
|
||||||
|
public async Task<ActionResult<BacktestJobStatusResponse>> 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<IBacktestJobRepository>();
|
||||||
|
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<LightBacktest>(job.ResultJson)
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a paginated list of jobs with optional filters and sorting.
|
||||||
|
/// Admin only endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="page">Page number (defaults to 1)</param>
|
||||||
|
/// <param name="pageSize">Number of items per page (defaults to 50, max 100)</param>
|
||||||
|
/// <param name="sortBy">Field to sort by (CreatedAt, StartedAt, CompletedAt, Priority, Status, JobType) - defaults to CreatedAt</param>
|
||||||
|
/// <param name="sortOrder">Sort order - "asc" or "desc" (defaults to "desc")</param>
|
||||||
|
/// <param name="status">Optional status filter (Pending, Running, Completed, Failed, Cancelled)</param>
|
||||||
|
/// <param name="jobType">Optional job type filter (Backtest, GeneticBacktest)</param>
|
||||||
|
/// <param name="userId">Optional user ID filter</param>
|
||||||
|
/// <param name="workerId">Optional worker ID filter</param>
|
||||||
|
/// <param name="bundleRequestId">Optional bundle request ID filter</param>
|
||||||
|
/// <returns>A paginated list of jobs</returns>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PaginatedJobsResponse>> 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<BacktestJobStatus>(status, true, out var parsedStatus))
|
||||||
|
{
|
||||||
|
statusFilter = parsedStatus;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BadRequest($"Invalid status value. Valid values are: {string.Join(", ", Enum.GetNames<BacktestJobStatus>())}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse job type filter
|
||||||
|
JobType? jobTypeFilter = null;
|
||||||
|
if (!string.IsNullOrEmpty(jobType))
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<JobType>(jobType, true, out var parsedJobType))
|
||||||
|
{
|
||||||
|
jobTypeFilter = parsedJobType;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BadRequest($"Invalid job type value. Valid values are: {string.Join(", ", Enum.GetNames<JobType>())}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<IBacktestJobRepository>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a summary of jobs grouped by status and job type with counts.
|
||||||
|
/// Admin only endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Summary statistics of jobs</returns>
|
||||||
|
[HttpGet("summary")]
|
||||||
|
public async Task<ActionResult<JobSummaryResponse>> 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<IBacktestJobRepository>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Managing.Domain.Backtests;
|
||||||
|
|
||||||
|
namespace Managing.Api.Models.Responses;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response model for backtest job status
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response model for bundle backtest status
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
79
src/Managing.Api/Models/Responses/PaginatedJobsResponse.cs
Normal file
79
src/Managing.Api/Models/Responses/PaginatedJobsResponse.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace Managing.Api.Models.Responses;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response model for paginated jobs list
|
||||||
|
/// </summary>
|
||||||
|
public class PaginatedJobsResponse
|
||||||
|
{
|
||||||
|
public List<JobListItemResponse> 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response model for a job list item (summary view)
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response model for job summary statistics
|
||||||
|
/// </summary>
|
||||||
|
public class JobSummaryResponse
|
||||||
|
{
|
||||||
|
public List<JobStatusSummary> StatusSummary { get; set; } = new();
|
||||||
|
public List<JobTypeSummary> JobTypeSummary { get; set; } = new();
|
||||||
|
public List<JobStatusTypeSummary> StatusTypeSummary { get; set; } = new();
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Summary of jobs by status
|
||||||
|
/// </summary>
|
||||||
|
public class JobStatusSummary
|
||||||
|
{
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Summary of jobs by job type
|
||||||
|
/// </summary>
|
||||||
|
public class JobTypeSummary
|
||||||
|
{
|
||||||
|
public string JobType { get; set; } = string.Empty;
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Summary of jobs by status and job type combination
|
||||||
|
/// </summary>
|
||||||
|
public class JobStatusTypeSummary
|
||||||
|
{
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string JobType { get; set; } = string.Empty;
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Managing.Domain.Backtests;
|
||||||
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
|
namespace Managing.Application.Abstractions.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for managing backtest jobs in the queue system
|
||||||
|
/// </summary>
|
||||||
|
public interface IBacktestJobRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new backtest job
|
||||||
|
/// </summary>
|
||||||
|
Task<BacktestJob> CreateAsync(BacktestJob job);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Claims the next available job using PostgreSQL advisory locks.
|
||||||
|
/// Returns null if no jobs are available.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workerId">The ID of the worker claiming the job</param>
|
||||||
|
/// <param name="jobType">Optional job type filter. If null, claims any job type.</param>
|
||||||
|
Task<BacktestJob?> ClaimNextJobAsync(string workerId, JobType? jobType = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing job
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateAsync(BacktestJob job);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all jobs for a specific bundle request
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<BacktestJob>> GetByBundleRequestIdAsync(Guid bundleRequestId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all jobs for a specific user
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<BacktestJob>> GetByUserIdAsync(int userId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a job by its ID
|
||||||
|
/// </summary>
|
||||||
|
Task<BacktestJob?> GetByIdAsync(Guid jobId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets stale jobs (jobs that are Running but haven't sent a heartbeat in the specified timeout)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeoutMinutes">Number of minutes since last heartbeat to consider stale</param>
|
||||||
|
Task<IEnumerable<BacktestJob>> GetStaleJobsAsync(int timeoutMinutes = 5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets stale jobs back to Pending status
|
||||||
|
/// </summary>
|
||||||
|
Task<int> ResetStaleJobsAsync(int timeoutMinutes = 5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all running jobs assigned to a specific worker
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<BacktestJob>> GetRunningJobsByWorkerIdAsync(string workerId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all jobs for a specific genetic request ID
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<BacktestJob>> GetByGeneticRequestIdAsync(string geneticRequestId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets paginated jobs with optional filters and sorting
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="page">Page number (1-based)</param>
|
||||||
|
/// <param name="pageSize">Number of items per page</param>
|
||||||
|
/// <param name="sortBy">Field to sort by</param>
|
||||||
|
/// <param name="sortOrder">Sort order ("asc" or "desc")</param>
|
||||||
|
/// <param name="status">Optional status filter</param>
|
||||||
|
/// <param name="jobType">Optional job type filter</param>
|
||||||
|
/// <param name="userId">Optional user ID filter</param>
|
||||||
|
/// <param name="workerId">Optional worker ID filter</param>
|
||||||
|
/// <param name="bundleRequestId">Optional bundle request ID filter</param>
|
||||||
|
/// <returns>Tuple of jobs and total count</returns>
|
||||||
|
Task<(IEnumerable<BacktestJob> 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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets summary statistics of jobs grouped by status and job type
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Summary containing counts by status, job type, and their combinations</returns>
|
||||||
|
Task<JobSummary> GetSummaryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Summary statistics for jobs
|
||||||
|
/// </summary>
|
||||||
|
public class JobSummary
|
||||||
|
{
|
||||||
|
public List<JobStatusCount> StatusCounts { get; set; } = new();
|
||||||
|
public List<JobTypeCount> JobTypeCounts { get; set; } = new();
|
||||||
|
public List<JobStatusTypeCount> StatusTypeCounts { get; set; } = new();
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Count of jobs by status
|
||||||
|
/// </summary>
|
||||||
|
public class JobStatusCount
|
||||||
|
{
|
||||||
|
public BacktestJobStatus Status { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Count of jobs by job type
|
||||||
|
/// </summary>
|
||||||
|
public class JobTypeCount
|
||||||
|
{
|
||||||
|
public JobType JobType { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Count of jobs by status and job type combination
|
||||||
|
/// </summary>
|
||||||
|
public class JobStatusTypeCount
|
||||||
|
{
|
||||||
|
public BacktestJobStatus Status { get; set; }
|
||||||
|
public JobType JobType { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ using Managing.Domain.Strategies;
|
|||||||
using Managing.Domain.Strategies.Signals;
|
using Managing.Domain.Strategies.Signals;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -40,12 +42,16 @@ namespace Managing.Application.Tests
|
|||||||
var hubContext = new Mock<IHubContext<BacktestHub>>().Object;
|
var hubContext = new Mock<IHubContext<BacktestHub>>().Object;
|
||||||
var tradingBotLogger = TradingBaseTests.CreateTradingBotLogger();
|
var tradingBotLogger = TradingBaseTests.CreateTradingBotLogger();
|
||||||
var backtestLogger = TradingBaseTests.CreateBacktesterLogger();
|
var backtestLogger = TradingBaseTests.CreateBacktesterLogger();
|
||||||
|
ILoggerFactory loggerFactory = new NullLoggerFactory();
|
||||||
|
var backtestJobLogger = loggerFactory.CreateLogger<BacktestJobService>();
|
||||||
var botService = new Mock<IBotService>().Object;
|
var botService = new Mock<IBotService>().Object;
|
||||||
var agentService = new Mock<IAgentService>().Object;
|
var agentService = new Mock<IAgentService>().Object;
|
||||||
var _scopeFactory = new Mock<IServiceScopeFactory>();
|
var _scopeFactory = new Mock<IServiceScopeFactory>();
|
||||||
|
var backtestJobRepository = new Mock<IBacktestJobRepository>().Object;
|
||||||
|
var backtestJobService = new BacktestJobService(backtestJobRepository, backtestRepository, kaigenService, backtestJobLogger);
|
||||||
_backtester = new Backtester(_exchangeService, backtestRepository, backtestLogger,
|
_backtester = new Backtester(_exchangeService, backtestRepository, backtestLogger,
|
||||||
scenarioService, _accountService.Object, messengerService, kaigenService, hubContext, null,
|
scenarioService, _accountService.Object, messengerService, kaigenService, hubContext, _scopeFactory.Object,
|
||||||
_scopeFactory.Object);
|
backtestJobService);
|
||||||
_elapsedTimes = new List<double>();
|
_elapsedTimes = new List<double>();
|
||||||
|
|
||||||
// Initialize cross-platform file paths
|
// Initialize cross-platform file paths
|
||||||
|
|||||||
280
src/Managing.Application/Backtests/BacktestExecutor.cs
Normal file
280
src/Managing.Application/Backtests/BacktestExecutor.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for executing backtests without Orleans dependencies.
|
||||||
|
/// Extracted from BacktestTradingBotGrain to be reusable in compute workers.
|
||||||
|
/// </summary>
|
||||||
|
public class BacktestExecutor
|
||||||
|
{
|
||||||
|
private readonly ILogger<BacktestExecutor> _logger;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IBacktestRepository _backtestRepository;
|
||||||
|
private readonly IScenarioService _scenarioService;
|
||||||
|
private readonly IAccountService _accountService;
|
||||||
|
private readonly IMessengerService _messengerService;
|
||||||
|
|
||||||
|
public BacktestExecutor(
|
||||||
|
ILogger<BacktestExecutor> logger,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IBacktestRepository backtestRepository,
|
||||||
|
IScenarioService scenarioService,
|
||||||
|
IAccountService accountService,
|
||||||
|
IMessengerService messengerService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_backtestRepository = backtestRepository;
|
||||||
|
_scenarioService = scenarioService;
|
||||||
|
_accountService = accountService;
|
||||||
|
_messengerService = messengerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a backtest with the given configuration and candles.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The trading bot configuration</param>
|
||||||
|
/// <param name="candles">The candles to use for backtesting</param>
|
||||||
|
/// <param name="user">The user running the backtest</param>
|
||||||
|
/// <param name="save">Whether to save the backtest result</param>
|
||||||
|
/// <param name="withCandles">Whether to include candles in the result</param>
|
||||||
|
/// <param name="requestId">The request ID to associate with this backtest</param>
|
||||||
|
/// <param name="metadata">Additional metadata</param>
|
||||||
|
/// <param name="progressCallback">Optional callback for progress updates (0-100)</param>
|
||||||
|
/// <returns>The lightweight backtest result</returns>
|
||||||
|
public async Task<LightBacktest> ExecuteAsync(
|
||||||
|
TradingBotConfig config,
|
||||||
|
HashSet<Candle> candles,
|
||||||
|
User user,
|
||||||
|
bool save = false,
|
||||||
|
bool withCandles = false,
|
||||||
|
string requestId = null,
|
||||||
|
object metadata = null,
|
||||||
|
Func<int, Task> 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<Candle>();
|
||||||
|
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<Candle>())
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Backtest to LightBacktest
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a TradingBotBase instance for backtesting
|
||||||
|
/// </summary>
|
||||||
|
private async Task<TradingBotBase> 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<ILogger<TradingBotBase>>();
|
||||||
|
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
|
||||||
|
return tradingBot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends notification if backtest meets criteria
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
254
src/Managing.Application/Backtests/BacktestJobService.cs
Normal file
254
src/Managing.Application/Backtests/BacktestJobService.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for creating and managing backtest jobs in the queue
|
||||||
|
/// </summary>
|
||||||
|
public class BacktestJobService
|
||||||
|
{
|
||||||
|
private readonly IBacktestJobRepository _jobRepository;
|
||||||
|
private readonly IBacktestRepository _backtestRepository;
|
||||||
|
private readonly IKaigenService _kaigenService;
|
||||||
|
private readonly ILogger<BacktestJobService> _logger;
|
||||||
|
|
||||||
|
public BacktestJobService(
|
||||||
|
IBacktestJobRepository jobRepository,
|
||||||
|
IBacktestRepository backtestRepository,
|
||||||
|
IKaigenService kaigenService,
|
||||||
|
ILogger<BacktestJobService> logger)
|
||||||
|
{
|
||||||
|
_jobRepository = jobRepository;
|
||||||
|
_backtestRepository = backtestRepository;
|
||||||
|
_kaigenService = kaigenService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a single backtest job
|
||||||
|
/// </summary>
|
||||||
|
public async Task<BacktestJob> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates multiple backtest jobs from bundle variants
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<BacktestJob>> CreateBundleJobsAsync(
|
||||||
|
BundleBacktestRequest bundleRequest,
|
||||||
|
List<RunBacktestRequest> backtestRequests)
|
||||||
|
{
|
||||||
|
var jobs = new List<BacktestJob>();
|
||||||
|
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<LightIndicator>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
using Managing.Application.Abstractions;
|
using System.Text.Json;
|
||||||
using Managing.Application.Abstractions.Grains;
|
using Managing.Application.Abstractions;
|
||||||
using Managing.Application.Abstractions.Repositories;
|
using Managing.Application.Abstractions.Repositories;
|
||||||
using Managing.Application.Abstractions.Services;
|
using Managing.Application.Abstractions.Services;
|
||||||
using Managing.Application.Abstractions.Shared;
|
using Managing.Application.Abstractions.Shared;
|
||||||
using Managing.Application.Hubs;
|
using Managing.Application.Hubs;
|
||||||
using Managing.Core;
|
|
||||||
using Managing.Domain.Accounts;
|
using Managing.Domain.Accounts;
|
||||||
using Managing.Domain.Backtests;
|
using Managing.Domain.Backtests;
|
||||||
using Managing.Domain.Bots;
|
using Managing.Domain.Bots;
|
||||||
using Managing.Domain.Candles;
|
using Managing.Domain.Candles;
|
||||||
using Managing.Domain.Scenarios;
|
using Managing.Domain.MoneyManagements;
|
||||||
using Managing.Domain.Users;
|
using Managing.Domain.Users;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -30,7 +29,7 @@ namespace Managing.Application.Backtests
|
|||||||
private readonly IMessengerService _messengerService;
|
private readonly IMessengerService _messengerService;
|
||||||
private readonly IKaigenService _kaigenService;
|
private readonly IKaigenService _kaigenService;
|
||||||
private readonly IHubContext<BacktestHub> _hubContext;
|
private readonly IHubContext<BacktestHub> _hubContext;
|
||||||
private readonly IGrainFactory _grainFactory;
|
private readonly BacktestJobService _jobService;
|
||||||
|
|
||||||
public Backtester(
|
public Backtester(
|
||||||
IExchangeService exchangeService,
|
IExchangeService exchangeService,
|
||||||
@@ -41,8 +40,8 @@ namespace Managing.Application.Backtests
|
|||||||
IMessengerService messengerService,
|
IMessengerService messengerService,
|
||||||
IKaigenService kaigenService,
|
IKaigenService kaigenService,
|
||||||
IHubContext<BacktestHub> hubContext,
|
IHubContext<BacktestHub> hubContext,
|
||||||
IGrainFactory grainFactory,
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
IServiceScopeFactory serviceScopeFactory)
|
BacktestJobService jobService)
|
||||||
{
|
{
|
||||||
_exchangeService = exchangeService;
|
_exchangeService = exchangeService;
|
||||||
_backtestRepository = backtestRepository;
|
_backtestRepository = backtestRepository;
|
||||||
@@ -52,23 +51,23 @@ namespace Managing.Application.Backtests
|
|||||||
_messengerService = messengerService;
|
_messengerService = messengerService;
|
||||||
_kaigenService = kaigenService;
|
_kaigenService = kaigenService;
|
||||||
_hubContext = hubContext;
|
_hubContext = hubContext;
|
||||||
_grainFactory = grainFactory;
|
|
||||||
_serviceScopeFactory = serviceScopeFactory;
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
|
_jobService = jobService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs a trading bot backtest with the specified configuration and date range.
|
/// Creates a backtest job and returns immediately (fire-and-forget pattern).
|
||||||
/// Automatically handles different bot types based on config.BotType.
|
/// The job will be processed by compute workers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="config">The trading bot configuration (must include Scenario object or ScenarioName)</param>
|
/// <param name="config">The trading bot configuration (must include Scenario object or ScenarioName)</param>
|
||||||
/// <param name="startDate">The start date for the backtest</param>
|
/// <param name="startDate">The start date for the backtest</param>
|
||||||
/// <param name="endDate">The end date for the backtest</param>
|
/// <param name="endDate">The end date for the backtest</param>
|
||||||
/// <param name="user">The user running the backtest (optional)</param>
|
/// <param name="user">The user running the backtest (required)</param>
|
||||||
/// <param name="save">Whether to save the backtest results</param>
|
/// <param name="save">Whether to save the backtest results</param>
|
||||||
/// <param name="withCandles">Whether to include candles and indicators values in the response</param>
|
/// <param name="withCandles">Whether to include candles and indicators values in the response (ignored, always false for jobs)</param>
|
||||||
/// <param name="requestId">The request ID to associate with this backtest (optional)</param>
|
/// <param name="requestId">The request ID to associate with this backtest (optional)</param>
|
||||||
/// <param name="metadata">Additional metadata to associate with this backtest (optional)</param>
|
/// <param name="metadata">Additional metadata to associate with this backtest (optional)</param>
|
||||||
/// <returns>The lightweight backtest results</returns>
|
/// <returns>A lightweight backtest response with job ID (result will be available later via GetJobStatus)</returns>
|
||||||
public async Task<LightBacktestResponse> RunTradingBotBacktest(
|
public async Task<LightBacktestResponse> RunTradingBotBacktest(
|
||||||
TradingBotConfig config,
|
TradingBotConfig config,
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
@@ -79,59 +78,33 @@ namespace Managing.Application.Backtests
|
|||||||
string requestId = null,
|
string requestId = null,
|
||||||
object metadata = null)
|
object metadata = null)
|
||||||
{
|
{
|
||||||
string creditRequestId = null;
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(user), "User is required for job-based backtests");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a job instead of running synchronously
|
||||||
|
var job = await _jobService.CreateJobAsync(
|
||||||
|
config,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
user,
|
||||||
|
priority: 0,
|
||||||
|
requestId: requestId);
|
||||||
|
|
||||||
// Debit user credits before starting the backtest
|
|
||||||
if (user != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
creditRequestId = await _kaigenService.DebitUserCreditsAsync(user, 1);
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Successfully debited credits for user {UserName} with request ID {RequestId}",
|
"Created backtest job {JobId} for user {UserId}. Job will be processed by compute workers.",
|
||||||
user.Name, creditRequestId);
|
job.Id, user.Id);
|
||||||
}
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
// Return a placeholder response with job ID
|
||||||
|
// The actual result will be available via GetJobStatus endpoint
|
||||||
|
return new LightBacktestResponse
|
||||||
{
|
{
|
||||||
var candles = await GetCandles(config.Ticker, config.Timeframe, startDate, endDate);
|
Id = job.Id.ToString(),
|
||||||
return await RunBacktestWithCandles(config, candles, user, save, withCandles, requestId, metadata);
|
Config = config,
|
||||||
}
|
Score = 0, // Placeholder, actual score will be available when job completes
|
||||||
catch (Exception ex)
|
ScoreMessage = $"Job {job.Id} is queued for processing"
|
||||||
{
|
};
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -153,67 +126,21 @@ namespace Managing.Application.Backtests
|
|||||||
string requestId = null,
|
string requestId = null,
|
||||||
object metadata = 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)
|
||||||
/// <summary>
|
|
||||||
/// Core backtesting logic - handles the actual backtest execution with pre-loaded candles
|
|
||||||
/// </summary>
|
|
||||||
private async Task<LightBacktestResponse> RunBacktestWithCandles(
|
|
||||||
TradingBotConfig config,
|
|
||||||
HashSet<Candle> candles,
|
|
||||||
User user = null,
|
|
||||||
bool save = false,
|
|
||||||
bool withCandles = false,
|
|
||||||
string requestId = null,
|
|
||||||
object metadata = null)
|
|
||||||
{
|
{
|
||||||
// Ensure this is a backtest configuration
|
throw new ArgumentNullException(nameof(user), "User is required");
|
||||||
if (!config.IsForBacktest)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Backtest configuration must have IsForBacktest set to true");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that scenario and indicators are properly loaded
|
var startDate = candles.Min(c => c.Date);
|
||||||
if (config.Scenario == null && string.IsNullOrEmpty(config.ScenarioName))
|
var endDate = candles.Max(c => c.Date);
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
return await RunTradingBotBacktest(config, startDate, endDate, user, false, withCandles, requestId, metadata);
|
||||||
"Backtest configuration must include either Scenario object or ScenarioName");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.Scenario == null && !string.IsNullOrEmpty(config.ScenarioName))
|
// Removed RunBacktestWithCandles - backtests now run via compute workers
|
||||||
{
|
// This method is kept for backward compatibility but should not be called directly
|
||||||
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<IBacktestTradingBotGrain>(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<IAgentService>(_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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<HashSet<Candle>> GetCandles(Ticker ticker, Timeframe timeframe,
|
private async Task<HashSet<Candle>> GetCandles(Ticker ticker, Timeframe timeframe,
|
||||||
DateTime startDate, DateTime endDate)
|
DateTime startDate, DateTime endDate)
|
||||||
@@ -229,16 +156,7 @@ namespace Managing.Application.Backtests
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
// Removed CreateCleanConfigForOrleans - no longer needed with job queue approach
|
||||||
/// Creates a clean copy of the trading bot config for Orleans serialization
|
|
||||||
/// Uses LightScenario and LightIndicator to avoid FixedSizeQueue serialization issues
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest)
|
private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest)
|
||||||
{
|
{
|
||||||
@@ -464,8 +382,121 @@ namespace Managing.Application.Backtests
|
|||||||
|
|
||||||
if (!saveAsTemplate)
|
if (!saveAsTemplate)
|
||||||
{
|
{
|
||||||
// Trigger the BundleBacktestGrain to process this request
|
// Generate backtest requests from variants (same logic as BundleBacktestGrain)
|
||||||
await TriggerBundleBacktestGrainAsync(bundleRequest.RequestId);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates individual backtest requests from variant configuration
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<RunBacktestRequest>> GenerateBacktestRequestsFromVariants(
|
||||||
|
BundleBacktestRequest bundleRequest)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Deserialize the variant configurations
|
||||||
|
var universalConfig =
|
||||||
|
JsonSerializer.Deserialize<BundleBacktestUniversalConfig>(bundleRequest.UniversalConfigJson);
|
||||||
|
var dateTimeRanges = JsonSerializer.Deserialize<List<DateTimeRange>>(bundleRequest.DateTimeRangesJson);
|
||||||
|
var moneyManagementVariants =
|
||||||
|
JsonSerializer.Deserialize<List<MoneyManagementVariant>>(bundleRequest.MoneyManagementVariantsJson);
|
||||||
|
var tickerVariants = JsonSerializer.Deserialize<List<Ticker>>(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<RunBacktestRequest>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<RunBacktestRequest>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var backtestRequests = new List<RunBacktestRequest>();
|
||||||
|
|
||||||
|
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<RunBacktestRequest>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,64 +561,6 @@ namespace Managing.Application.Backtests
|
|||||||
await _hubContext.Clients.Group($"bundle-{requestId}").SendAsync("BundleBacktestUpdate", response);
|
await _hubContext.Clients.Group($"bundle-{requestId}").SendAsync("BundleBacktestUpdate", response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Removed TriggerBundleBacktestGrain methods - bundle backtests now use job queue
|
||||||
/// Triggers the BundleBacktestGrain to process a bundle request synchronously (fire and forget)
|
|
||||||
/// </summary>
|
|
||||||
private void TriggerBundleBacktestGrain(Guid bundleRequestId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bundleBacktestGrain = _grainFactory.GetGrain<IBundleBacktestGrain>(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Triggers the BundleBacktestGrain to process a bundle request asynchronously
|
|
||||||
/// </summary>
|
|
||||||
private Task TriggerBundleBacktestGrainAsync(Guid bundleRequestId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bundleBacktestGrain = _grainFactory.GetGrain<IBundleBacktestGrain>(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ public class UserService : IUserService
|
|||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
private readonly ILogger<UserService> _logger;
|
private readonly ILogger<UserService> _logger;
|
||||||
private readonly ICacheService _cacheService;
|
private readonly ICacheService _cacheService;
|
||||||
private readonly IGrainFactory _grainFactory;
|
private readonly IGrainFactory? _grainFactory;
|
||||||
private readonly IWhitelistService _whitelistService;
|
private readonly IWhitelistService _whitelistService;
|
||||||
private readonly string[] _authorizedAddresses;
|
private readonly string[] _authorizedAddresses;
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ public class UserService : IUserService
|
|||||||
IAccountService accountService,
|
IAccountService accountService,
|
||||||
ILogger<UserService> logger,
|
ILogger<UserService> logger,
|
||||||
ICacheService cacheService,
|
ICacheService cacheService,
|
||||||
IGrainFactory grainFactory,
|
IGrainFactory? grainFactory,
|
||||||
IWhitelistService whitelistService,
|
IWhitelistService whitelistService,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
@@ -134,6 +134,9 @@ public class UserService : IUserService
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initialize AgentGrain for new user (with empty agent name initially)
|
// Initialize AgentGrain for new user (with empty agent name initially)
|
||||||
|
// Only if Orleans is available (not available in compute workers)
|
||||||
|
if (_grainFactory != null)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
|
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
|
||||||
@@ -147,6 +150,7 @@ public class UserService : IUserService
|
|||||||
// Don't throw here to avoid breaking the user creation process
|
// Don't throw here to avoid breaking the user creation process
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -199,6 +203,9 @@ public class UserService : IUserService
|
|||||||
await _userRepository.SaveOrUpdateUserAsync(user);
|
await _userRepository.SaveOrUpdateUserAsync(user);
|
||||||
|
|
||||||
// Update the AgentGrain with the new agent name (lightweight operation)
|
// Update the AgentGrain with the new agent name (lightweight operation)
|
||||||
|
// Only if Orleans is available (not available in compute workers)
|
||||||
|
if (_grainFactory != null)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
|
var agentGrain = _grainFactory.GetGrain<IAgentGrain>(user.Id);
|
||||||
@@ -212,6 +219,7 @@ public class UserService : IUserService
|
|||||||
user.Id, agentName);
|
user.Id, agentName);
|
||||||
// Don't throw here to avoid breaking the user update process
|
// Don't throw here to avoid breaking the user update process
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
422
src/Managing.Application/Workers/BacktestComputeWorker.cs
Normal file
422
src/Managing.Application/Workers/BacktestComputeWorker.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background worker that processes backtest jobs from the queue.
|
||||||
|
/// Polls for pending jobs, claims them using advisory locks, and processes them.
|
||||||
|
/// </summary>
|
||||||
|
public class BacktestComputeWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<BacktestComputeWorker> _logger;
|
||||||
|
private readonly BacktestComputeWorkerOptions _options;
|
||||||
|
private readonly SemaphoreSlim _semaphore;
|
||||||
|
|
||||||
|
public BacktestComputeWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<BacktestComputeWorker> logger,
|
||||||
|
IOptions<BacktestComputeWorkerOptions> 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<IBacktestJobRepository>();
|
||||||
|
|
||||||
|
// 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<IBacktestJobRepository>();
|
||||||
|
var executor = scope.ServiceProvider.GetRequiredService<BacktestExecutor>();
|
||||||
|
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
|
||||||
|
var exchangeService = scope.ServiceProvider.GetRequiredService<IExchangeService>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Processing backtest job {JobId} (BundleRequestId: {BundleRequestId}, UserId: {UserId})",
|
||||||
|
job.Id, job.BundleRequestId, job.UserId);
|
||||||
|
|
||||||
|
// Deserialize config
|
||||||
|
var config = JsonSerializer.Deserialize<TradingBotConfig>(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<int, Task> 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<IBacktestRepository>();
|
||||||
|
var jobRepository = serviceProvider.GetRequiredService<IBacktestJobRepository>();
|
||||||
|
var userService = serviceProvider.GetRequiredService<IUserService>();
|
||||||
|
|
||||||
|
// 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<LightBacktest>(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<IBacktestJobRepository>();
|
||||||
|
|
||||||
|
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<IBacktestJobRepository>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for BacktestComputeWorker
|
||||||
|
/// </summary>
|
||||||
|
public class BacktestComputeWorkerOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "BacktestComputeWorker";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for this worker instance
|
||||||
|
/// </summary>
|
||||||
|
public string WorkerId { get; set; } = Environment.MachineName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of concurrent backtests to process
|
||||||
|
/// </summary>
|
||||||
|
public int MaxConcurrentBacktests { get; set; } = 6;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interval in seconds between job polling attempts
|
||||||
|
/// </summary>
|
||||||
|
public int JobPollIntervalSeconds { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interval in seconds between heartbeat updates
|
||||||
|
/// </summary>
|
||||||
|
public int HeartbeatIntervalSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timeout in minutes for considering a job stale
|
||||||
|
/// </summary>
|
||||||
|
public int StaleJobTimeoutMinutes { get; set; } = 5;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -400,6 +400,7 @@ public static class ApiBootstrap
|
|||||||
|
|
||||||
// Processors
|
// Processors
|
||||||
services.AddTransient<IBacktester, Backtester>();
|
services.AddTransient<IBacktester, Backtester>();
|
||||||
|
services.AddTransient<BacktestJobService>();
|
||||||
services.AddTransient<IExchangeProcessor, EvmProcessor>();
|
services.AddTransient<IExchangeProcessor, EvmProcessor>();
|
||||||
|
|
||||||
services.AddTransient<ITradaoService, TradaoService>();
|
services.AddTransient<ITradaoService, TradaoService>();
|
||||||
@@ -442,6 +443,7 @@ public static class ApiBootstrap
|
|||||||
|
|
||||||
services.AddTransient<IAccountRepository, PostgreSqlAccountRepository>();
|
services.AddTransient<IAccountRepository, PostgreSqlAccountRepository>();
|
||||||
services.AddTransient<IBacktestRepository, PostgreSqlBacktestRepository>();
|
services.AddTransient<IBacktestRepository, PostgreSqlBacktestRepository>();
|
||||||
|
services.AddTransient<IBacktestJobRepository, PostgreSqlJobRepository>();
|
||||||
services.AddTransient<IGeneticRepository, PostgreSqlGeneticRepository>();
|
services.AddTransient<IGeneticRepository, PostgreSqlGeneticRepository>();
|
||||||
services.AddTransient<ITradingRepository, PostgreSqlTradingRepository>();
|
services.AddTransient<ITradingRepository, PostgreSqlTradingRepository>();
|
||||||
services.AddTransient<ISettingsRepository, PostgreSqlSettingsRepository>();
|
services.AddTransient<ISettingsRepository, PostgreSqlSettingsRepository>();
|
||||||
|
|||||||
170
src/Managing.Bootstrap/ComputeBootstrap.cs
Normal file
170
src/Managing.Bootstrap/ComputeBootstrap.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bootstrap configuration for compute worker project (no Orleans)
|
||||||
|
/// </summary>
|
||||||
|
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<ITradingService, TradingService>();
|
||||||
|
services.AddScoped<IScenarioService, ScenarioService>();
|
||||||
|
services.AddScoped<IMoneyManagementService, MoneyManagementService>();
|
||||||
|
services.AddScoped<IAccountService, AccountService>();
|
||||||
|
services.AddScoped<IUserService, UserService>();
|
||||||
|
|
||||||
|
// Services not needed for compute worker (depend on IBacktester/Orleans)
|
||||||
|
// services.AddScoped<IStatisticService, StatisticService>(); // Requires IBacktester
|
||||||
|
// services.AddScoped<ISettingsService, SettingsService>(); // Requires IBacktester
|
||||||
|
// services.AddScoped<IGeneticService, GeneticService>(); // Requires IBacktester
|
||||||
|
// services.AddScoped<IAgentService, AgentService>(); // May require Orleans
|
||||||
|
// services.AddScoped<IBotService, BotService>(); // May require Orleans
|
||||||
|
// services.AddScoped<IWorkerService, WorkerService>(); // May require Orleans
|
||||||
|
// services.AddScoped<IPricesService, PricesService>(); // May require Orleans
|
||||||
|
|
||||||
|
// Processors
|
||||||
|
// Note: IBacktester not needed for compute worker - BacktestExecutor is used directly
|
||||||
|
services.AddTransient<BacktestExecutor>();
|
||||||
|
services.AddTransient<IExchangeProcessor, EvmProcessor>();
|
||||||
|
|
||||||
|
services.AddTransient<ITradaoService, TradaoService>();
|
||||||
|
services.AddTransient<IExchangeService, ExchangeService>();
|
||||||
|
|
||||||
|
// Web3Proxy service (needed for EvmManager)
|
||||||
|
services.AddTransient<IWeb3ProxyService, Web3ProxyService>();
|
||||||
|
|
||||||
|
// Evm services (needed for ExchangeService)
|
||||||
|
services.AddGbcFeed();
|
||||||
|
services.AddUniswapV2();
|
||||||
|
services.AddChainlink();
|
||||||
|
services.AddChainlinkGmx();
|
||||||
|
services.AddSingleton<IEvmManager, EvmManager>();
|
||||||
|
|
||||||
|
services.AddTransient<IKaigenService, KaigenService>();
|
||||||
|
services.AddTransient<IWhitelistService, WhitelistService>();
|
||||||
|
|
||||||
|
// Synth services (needed for TradingService)
|
||||||
|
services.AddScoped<ISynthPredictionService, SynthPredictionService>();
|
||||||
|
services.AddScoped<ISynthApiClient, SynthApiClient>();
|
||||||
|
|
||||||
|
// No-op implementations for compute worker (no Discord needed)
|
||||||
|
services.AddSingleton<IDiscordService, NoOpDiscordService>();
|
||||||
|
// IGrainFactory is optional in UserService - register as null for compute workers
|
||||||
|
services.AddSingleton<IGrainFactory>(sp => null!);
|
||||||
|
|
||||||
|
// Webhook service (required for notifications)
|
||||||
|
services.AddHttpClient<IWebhookService, WebhookService>();
|
||||||
|
// MessengerService must be Scoped because it depends on IUserService which is Scoped
|
||||||
|
services.AddScoped<IMessengerService, MessengerService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// Database
|
||||||
|
services.AddSingleton<IPostgreSqlSettings>(sp =>
|
||||||
|
sp.GetRequiredService<IOptions<PostgreSqlSettings>>().Value);
|
||||||
|
|
||||||
|
services.AddSingleton<IInfluxDbSettings>(sp =>
|
||||||
|
sp.GetRequiredService<IOptions<InfluxDbSettings>>().Value);
|
||||||
|
|
||||||
|
services.Configure<KaigenSettings>(configuration.GetSection("Kaigen"));
|
||||||
|
services.Configure<Web3ProxySettings>(configuration.GetSection("Web3Proxy"));
|
||||||
|
|
||||||
|
// SQL Monitoring (required by repositories)
|
||||||
|
services.Configure<SqlMonitoringSettings>(configuration.GetSection("SqlMonitoring"));
|
||||||
|
services.AddSingleton<SentrySqlMonitoringService>();
|
||||||
|
|
||||||
|
// PostgreSql Repositories
|
||||||
|
services.AddTransient<IAccountRepository, PostgreSqlAccountRepository>();
|
||||||
|
services.AddTransient<IBacktestRepository, PostgreSqlBacktestRepository>();
|
||||||
|
services.AddTransient<IBacktestJobRepository, PostgreSqlJobRepository>();
|
||||||
|
services.AddTransient<IGeneticRepository, PostgreSqlGeneticRepository>();
|
||||||
|
services.AddTransient<ITradingRepository, PostgreSqlTradingRepository>();
|
||||||
|
services.AddTransient<ISettingsRepository, PostgreSqlSettingsRepository>();
|
||||||
|
services.AddTransient<IUserRepository, PostgreSqlUserRepository>();
|
||||||
|
services.AddTransient<IAgentSummaryRepository, AgentSummaryRepository>();
|
||||||
|
services.AddTransient<IStatisticRepository, PostgreSqlStatisticRepository>();
|
||||||
|
services.AddTransient<IBotRepository, PostgreSqlBotRepository>();
|
||||||
|
services.AddTransient<IWorkerRepository, PostgreSqlWorkerRepository>();
|
||||||
|
services.AddTransient<ISynthRepository, PostgreSqlSynthRepository>();
|
||||||
|
services.AddTransient<IWhitelistRepository, PostgreSqlWhitelistRepository>();
|
||||||
|
|
||||||
|
// InfluxDb Repositories
|
||||||
|
services.AddSingleton<IInfluxDbRepository, InfluxDbRepository>();
|
||||||
|
services.AddSingleton<ICandleRepository, CandleRepository>();
|
||||||
|
services.AddSingleton<IAgentBalanceRepository, AgentBalanceRepository>();
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
services.AddDistributedMemoryCache();
|
||||||
|
services.AddTransient<ICacheService, CacheService>();
|
||||||
|
services.AddSingleton<ITaskCache, TaskCache>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-op implementation of IDiscordService for compute workers
|
||||||
|
/// </summary>
|
||||||
|
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<Trader> traders) => Task.CompletedTask;
|
||||||
|
public Task SendBadTraders(List<Trader> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<PackageReference Include="MediatR" Version="12.2.0"/>
|
<PackageReference Include="MediatR" Version="12.2.0"/>
|
||||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0"/>
|
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0"/>
|
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Core" Version="1.1.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.7"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.7"/>
|
||||||
|
|||||||
@@ -550,4 +550,20 @@ public static class Enums
|
|||||||
InsufficientEthBelowMinimum,
|
InsufficientEthBelowMinimum,
|
||||||
BotsHaveOpenPositions
|
BotsHaveOpenPositions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type of job in the job queue system
|
||||||
|
/// </summary>
|
||||||
|
public enum JobType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Standard backtest job
|
||||||
|
/// </summary>
|
||||||
|
Backtest,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Genetic algorithm backtest job
|
||||||
|
/// </summary>
|
||||||
|
GeneticBacktest
|
||||||
|
}
|
||||||
}
|
}
|
||||||
157
src/Managing.Domain/Backtests/BacktestJob.cs
Normal file
157
src/Managing.Domain/Backtests/BacktestJob.cs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
|
namespace Managing.Domain.Backtests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single backtest job in the queue system.
|
||||||
|
/// Can be a standalone backtest or part of a bundle backtest request.
|
||||||
|
/// </summary>
|
||||||
|
public class BacktestJob
|
||||||
|
{
|
||||||
|
public BacktestJob()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid();
|
||||||
|
CreatedAt = DateTime.UtcNow;
|
||||||
|
Status = BacktestJobStatus.Pending;
|
||||||
|
ProgressPercentage = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for the job
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional bundle request ID if this job is part of a bundle
|
||||||
|
/// </summary>
|
||||||
|
public Guid? BundleRequestId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User ID who created this job
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public int UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current status of the job
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public BacktestJobStatus Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Priority of the job (higher = more important)
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public int Priority { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialized TradingBotConfig JSON
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string ConfigJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start date for the backtest
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public DateTime StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End date for the backtest
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public DateTime EndDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Progress percentage (0-100)
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public int ProgressPercentage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Worker ID that has claimed this job
|
||||||
|
/// </summary>
|
||||||
|
public string? AssignedWorkerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last heartbeat timestamp from the assigned worker
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastHeartbeat { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the job was created
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the job started processing
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the job completed (successfully or failed)
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialized LightBacktest result JSON (when completed)
|
||||||
|
/// </summary>
|
||||||
|
public string? ResultJson { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error message if the job failed
|
||||||
|
/// </summary>
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request ID to associate with the backtest result (for grouping)
|
||||||
|
/// </summary>
|
||||||
|
public string? RequestId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type of job (Backtest or GeneticBacktest)
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public JobType JobType { get; set; } = JobType.Backtest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional genetic request ID if this is a genetic backtest job
|
||||||
|
/// </summary>
|
||||||
|
public string? GeneticRequestId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status of a backtest job
|
||||||
|
/// </summary>
|
||||||
|
public enum BacktestJobStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Job is pending and waiting to be claimed by a worker
|
||||||
|
/// </summary>
|
||||||
|
Pending,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Job is currently being processed by a worker
|
||||||
|
/// </summary>
|
||||||
|
Running,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Job completed successfully
|
||||||
|
/// </summary>
|
||||||
|
Completed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Job failed with an error
|
||||||
|
/// </summary>
|
||||||
|
Failed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Job was cancelled
|
||||||
|
/// </summary>
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Managing.Infrastructure.Databases.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobTypeAndGeneticRequestIdToBacktestJobs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "BacktestJobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
BundleRequestId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
JobType = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
||||||
|
Priority = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
||||||
|
ConfigJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||||
|
StartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
EndDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ProgressPercentage = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
|
||||||
|
AssignedWorkerId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||||
|
LastHeartbeat = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ResultJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "text", nullable: true),
|
||||||
|
RequestId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||||
|
GeneticRequestId = table.Column<string>(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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "BacktestJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -706,6 +706,95 @@ namespace Managing.Infrastructure.Databases.Migrations
|
|||||||
b.ToTable("Indicators");
|
b.ToTable("Indicators");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedWorkerId")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("BundleRequestId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ConfigJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EndDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("GeneticRequestId")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<int>("JobType")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastHeartbeat")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<int>("ProgressPercentage")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<string>("RequestId")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("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 =>
|
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1494,6 +1583,17 @@ namespace Managing.Infrastructure.Databases.Migrations
|
|||||||
b.Navigation("User");
|
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 =>
|
modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User")
|
b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User")
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ public class ManagingDbContext : DbContext
|
|||||||
public DbSet<GeneticRequestEntity> GeneticRequests { get; set; }
|
public DbSet<GeneticRequestEntity> GeneticRequests { get; set; }
|
||||||
public DbSet<BacktestEntity> Backtests { get; set; }
|
public DbSet<BacktestEntity> Backtests { get; set; }
|
||||||
public DbSet<BundleBacktestRequestEntity> BundleBacktestRequests { get; set; }
|
public DbSet<BundleBacktestRequestEntity> BundleBacktestRequests { get; set; }
|
||||||
|
public DbSet<JobEntity> Jobs { get; set; }
|
||||||
|
|
||||||
// Trading entities
|
// Trading entities
|
||||||
public DbSet<ScenarioEntity> Scenarios { get; set; }
|
public DbSet<ScenarioEntity> Scenarios { get; set; }
|
||||||
@@ -231,6 +232,45 @@ public class ManagingDbContext : DbContext
|
|||||||
entity.HasIndex(e => new { e.UserId, e.Name, e.Version });
|
entity.HasIndex(e => new { e.UserId, e.Name, e.Version });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Configure BacktestJob entity
|
||||||
|
modelBuilder.Entity<JobEntity>(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
|
// Configure Scenario entity
|
||||||
modelBuilder.Entity<ScenarioEntity>(entity =>
|
modelBuilder.Entity<ScenarioEntity>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<PostgreSqlJobRepository> _logger;
|
||||||
|
|
||||||
|
public PostgreSqlJobRepository(
|
||||||
|
ManagingDbContext context,
|
||||||
|
ILogger<PostgreSqlJobRepository> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BacktestJob> CreateAsync(BacktestJob job)
|
||||||
|
{
|
||||||
|
var entity = MapToEntity(job);
|
||||||
|
_context.Jobs.Add(entity);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return MapToDomain(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BacktestJob?> 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<object> { (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<IEnumerable<BacktestJob>> GetByBundleRequestIdAsync(Guid bundleRequestId)
|
||||||
|
{
|
||||||
|
var entities = await _context.Jobs
|
||||||
|
.Where(j => j.BundleRequestId == bundleRequestId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return entities.Select(MapToDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<BacktestJob>> GetByUserIdAsync(int userId)
|
||||||
|
{
|
||||||
|
var entities = await _context.Jobs
|
||||||
|
.Where(j => j.UserId == userId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return entities.Select(MapToDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all running jobs assigned to a specific worker
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IEnumerable<BacktestJob>> 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<IEnumerable<BacktestJob>> GetByGeneticRequestIdAsync(string geneticRequestId)
|
||||||
|
{
|
||||||
|
var entities = await _context.Jobs
|
||||||
|
.Where(j => j.GeneticRequestId == geneticRequestId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return entities.Select(MapToDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<BacktestJob> 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<BacktestJob?> GetByIdAsync(Guid jobId)
|
||||||
|
{
|
||||||
|
var entity = await _context.Jobs
|
||||||
|
.FirstOrDefaultAsync(j => j.Id == jobId);
|
||||||
|
|
||||||
|
return entity != null ? MapToDomain(entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<BacktestJob>> 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<int> 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<JobSummary> 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<StatusCountResult>();
|
||||||
|
var jobTypeCounts = new List<JobTypeCountResult>();
|
||||||
|
var statusTypeCounts = new List<StatusTypeCountResult>();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"@vitejs/plugin-react": "^1.3.2",
|
"@vitejs/plugin-react": "^1.3.2",
|
||||||
"all-contributors-cli": "^6.20.0",
|
"all-contributors-cli": "^6.20.0",
|
||||||
"autoprefixer": "^10.4.7",
|
"autoprefixer": "^10.4.7",
|
||||||
"daisyui": "^3.5.1",
|
"daisyui": "^5.4.7",
|
||||||
"postcss": "^8.4.13",
|
"postcss": "^8.4.13",
|
||||||
"prettier": "^2.6.1",
|
"prettier": "^2.6.1",
|
||||||
"prettier-plugin-tailwind-css": "^1.5.0",
|
"prettier-plugin-tailwind-css": "^1.5.0",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface BottomMenuBarProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const BottomMenuBar: React.FC<BottomMenuBarProps> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<ul className="menu menu-horizontal bg-base-200 rounded-box fixed bottom-4 left-1/2 transform -translate-x-1/2 shadow-2xl z-50">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BottomMenuBar
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './BottomMenuBar'
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ const Tabs: FC<ITabsProps> = ({
|
|||||||
{tabs.map((tab: any) => (
|
{tabs.map((tab: any) => (
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
'tab whitespace-nowrap flex-shrink-0 px-5 py-2.5 text-sm font-medium transition-all duration-200 rounded-md ' +
|
'tab whitespace-nowrap flex-shrink-0 px-5 py-2.5 text-sm font-medium transition-all duration-200 rounded-md flex items-center justify-center ' +
|
||||||
(selectedTab === tab.index
|
(selectedTab === tab.index
|
||||||
? 'tab-active bg-primary text-primary-content shadow-md font-semibold'
|
? 'tab-active bg-primary text-primary-content shadow-md font-semibold'
|
||||||
: 'text-base-content/70 hover:text-base-content hover:bg-base-300/50')
|
: 'text-base-content/70 hover:text-base-content hover:bg-base-300/50')
|
||||||
@@ -55,7 +55,7 @@ const Tabs: FC<ITabsProps> = ({
|
|||||||
))}
|
))}
|
||||||
{addButton && (
|
{addButton && (
|
||||||
<button
|
<button
|
||||||
className="tab whitespace-nowrap flex-shrink-0 px-5 py-2.5 text-sm font-medium transition-all duration-200 rounded-md text-base-content/70 hover:text-base-content hover:bg-base-300/50"
|
className="tab whitespace-nowrap flex-shrink-0 px-5 py-2.5 text-sm font-medium transition-all duration-200 rounded-md flex items-center justify-center text-base-content/70 hover:text-base-content hover:bg-base-300/50"
|
||||||
onClick={onAddButton}
|
onClick={onAddButton}
|
||||||
key={'add'}
|
key={'add'}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface UserAction {
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
color?: 'primary' | 'secondary' | 'accent' | 'info' | 'success' | 'warning' | 'error' | 'ghost'
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
showCondition?: boolean
|
||||||
|
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserActionsButtonProps {
|
||||||
|
mainAction: UserAction
|
||||||
|
actions: UserAction[]
|
||||||
|
mainButtonIcon: React.ReactNode
|
||||||
|
mainButtonColor?: 'primary' | 'secondary' | 'accent' | 'info' | 'success' | 'warning' | 'error' | 'ghost'
|
||||||
|
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserActionsButton: React.FC<UserActionsButtonProps> = ({
|
||||||
|
mainAction,
|
||||||
|
actions,
|
||||||
|
mainButtonIcon,
|
||||||
|
mainButtonColor = 'info',
|
||||||
|
position = 'bottom-right',
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const getPositionClasses = () => {
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom-right':
|
||||||
|
return 'fixed bottom-6 right-6'
|
||||||
|
case 'bottom-left':
|
||||||
|
return 'fixed bottom-6 left-6'
|
||||||
|
case 'top-right':
|
||||||
|
return 'fixed top-6 right-6'
|
||||||
|
case 'top-left':
|
||||||
|
return 'fixed top-6 left-6'
|
||||||
|
default:
|
||||||
|
return 'fixed bottom-6 right-6'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getButtonColorClass = (color?: string) => {
|
||||||
|
if (!color) return ''
|
||||||
|
return `btn-${color}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fab fab-flower ${getPositionClasses()} z-50 ${className}`}>
|
||||||
|
{/* Speed Dial buttons - arranged in quarter circle */}
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
// Skip rendering if showCondition is false
|
||||||
|
if (action.showCondition === false) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipPosition = action.tooltipPosition || (position.includes('right') ? 'left' : 'right')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className={`tooltip tooltip-${tooltipPosition}`} data-tip={action.label}>
|
||||||
|
<button
|
||||||
|
className={`btn btn-lg btn-circle ${getButtonColorClass(action.color)}`}
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled}
|
||||||
|
aria-label={action.label}
|
||||||
|
>
|
||||||
|
{action.loading ? (
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
) : (
|
||||||
|
action.icon
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserActionsButton
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from './UserActionsButton'
|
||||||
|
export type { UserActionsButtonProps, UserAction } from './UserActionsButton'
|
||||||
|
|
||||||
@@ -15,3 +15,6 @@ export { default as Card } from './Card/Card'
|
|||||||
export { default as ConfigDisplayModal } from './ConfigDisplayModal/ConfigDisplayModal'
|
export { default as ConfigDisplayModal } from './ConfigDisplayModal/ConfigDisplayModal'
|
||||||
export { default as IndicatorsDisplay } from './IndicatorsDisplay/IndicatorsDisplay'
|
export { default as IndicatorsDisplay } from './IndicatorsDisplay/IndicatorsDisplay'
|
||||||
export { default as PlatformLineChart } from './PlatformLineChart/PlatformLineChart'
|
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'
|
||||||
|
|||||||
@@ -160,11 +160,12 @@ interface BacktestTableProps {
|
|||||||
durationMinDays?: number | null
|
durationMinDays?: number | null
|
||||||
durationMaxDays?: number | null
|
durationMaxDays?: number | null
|
||||||
}
|
}
|
||||||
|
openFiltersTrigger?: number // When this changes, open the filter sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortChange, currentSort, onBacktestDeleted, onFiltersChange, filters}) => {
|
const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortChange, currentSort, onBacktestDeleted, onFiltersChange, filters, openFiltersTrigger}) => {
|
||||||
const [rows, setRows] = useState<LightBacktestResponse[]>([])
|
const [rows, setRows] = useState<LightBacktestResponse[]>([])
|
||||||
const {apiUrl} = useApiUrlStore()
|
const {apiUrl} = useApiUrlStore()
|
||||||
const {removeBacktest} = useBacktestStore()
|
const {removeBacktest} = useBacktestStore()
|
||||||
@@ -198,6 +199,41 @@ const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortCh
|
|||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = 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 = () => {
|
const applyFilters = () => {
|
||||||
if (!onFiltersChange) return
|
if (!onFiltersChange) return
|
||||||
onFiltersChange({
|
onFiltersChange({
|
||||||
@@ -299,6 +335,13 @@ const BacktestTable: React.FC<BacktestTableProps> = ({list, isFetching, onSortCh
|
|||||||
setDurationMaxDays(filters.durationMaxDays ?? null)
|
setDurationMaxDays(filters.durationMaxDays ?? null)
|
||||||
}, [filters])
|
}, [filters])
|
||||||
|
|
||||||
|
// Handle external trigger to open filters
|
||||||
|
useEffect(() => {
|
||||||
|
if (openFiltersTrigger && openFiltersTrigger > 0) {
|
||||||
|
setIsFilterOpen(true)
|
||||||
|
}
|
||||||
|
}, [openFiltersTrigger])
|
||||||
|
|
||||||
// Handle sort change
|
// Handle sort change
|
||||||
const handleSortChange = (columnId: string, sortOrder: 'asc' | 'desc') => {
|
const handleSortChange = (columnId: string, sortOrder: 'asc' | 'desc') => {
|
||||||
if (!onSortChange) return;
|
if (!onSortChange) return;
|
||||||
|
|||||||
@@ -1072,6 +1072,44 @@ export class BacktestClient extends AuthorizedApiBase {
|
|||||||
return Promise.resolve<FileResponse>(null as any);
|
return Promise.resolve<FileResponse>(null as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backtest_GetBundleStatus(bundleRequestId: string): Promise<BundleBacktestStatusResponse> {
|
||||||
|
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<BundleBacktestStatusResponse> {
|
||||||
|
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<BundleBacktestStatusResponse>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
backtest_RunGenetic(request: RunGeneticRequest): Promise<GeneticRequest> {
|
backtest_RunGenetic(request: RunGeneticRequest): Promise<GeneticRequest> {
|
||||||
let url_ = this.baseUrl + "/Backtest/Genetic";
|
let url_ = this.baseUrl + "/Backtest/Genetic";
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
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<Response> };
|
||||||
|
private baseUrl: string;
|
||||||
|
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
|
||||||
|
|
||||||
|
constructor(configuration: IConfig, baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }) {
|
||||||
|
super(configuration);
|
||||||
|
this.http = http ? http : window as any;
|
||||||
|
this.baseUrl = baseUrl ?? "http://localhost:5000";
|
||||||
|
}
|
||||||
|
|
||||||
|
job_GetJobStatus(jobId: string): Promise<BacktestJobStatusResponse> {
|
||||||
|
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<BacktestJobStatusResponse> {
|
||||||
|
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<BacktestJobStatusResponse>(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<PaginatedJobsResponse> {
|
||||||
|
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<PaginatedJobsResponse> {
|
||||||
|
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<PaginatedJobsResponse>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
job_GetJobSummary(): Promise<JobSummaryResponse> {
|
||||||
|
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<JobSummaryResponse> {
|
||||||
|
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<JobSummaryResponse>(null as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class MoneyManagementClient extends AuthorizedApiBase {
|
export class MoneyManagementClient extends AuthorizedApiBase {
|
||||||
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
|
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
@@ -4740,6 +4924,20 @@ export interface BundleBacktestRequestViewModel {
|
|||||||
estimatedTimeRemainingSeconds?: number | null;
|
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 {
|
export interface GeneticRequest {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
user: User;
|
user: User;
|
||||||
@@ -5199,6 +5397,69 @@ export interface AgentBalance {
|
|||||||
time?: Date;
|
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 {
|
export interface ScenarioViewModel {
|
||||||
name: string;
|
name: string;
|
||||||
indicators: IndicatorViewModel[];
|
indicators: IndicatorViewModel[];
|
||||||
|
|||||||
@@ -735,6 +735,20 @@ export interface BundleBacktestRequestViewModel {
|
|||||||
estimatedTimeRemainingSeconds?: number | null;
|
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 {
|
export interface GeneticRequest {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
user: User;
|
user: User;
|
||||||
@@ -1194,6 +1208,69 @@ export interface AgentBalance {
|
|||||||
time?: Date;
|
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 {
|
export interface ScenarioViewModel {
|
||||||
name: string;
|
name: string;
|
||||||
indicators: IndicatorViewModel[];
|
indicators: IndicatorViewModel[];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {Tabs} from '../../components/mollecules'
|
|||||||
|
|
||||||
import AccountSettings from './account/accountSettings'
|
import AccountSettings from './account/accountSettings'
|
||||||
import WhitelistSettings from './whitelist/whitelistSettings'
|
import WhitelistSettings from './whitelist/whitelistSettings'
|
||||||
|
import JobsSettings from './jobs/jobsSettings'
|
||||||
|
|
||||||
type TabsType = {
|
type TabsType = {
|
||||||
label: string
|
label: string
|
||||||
@@ -23,6 +24,11 @@ const tabs: TabsType = [
|
|||||||
index: 2,
|
index: 2,
|
||||||
label: 'Account',
|
label: 'Account',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Component: JobsSettings,
|
||||||
|
index: 3,
|
||||||
|
label: 'Jobs',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const Admin: React.FC = () => {
|
const Admin: React.FC = () => {
|
||||||
|
|||||||
510
src/Managing.WebApp/src/pages/adminPage/jobs/jobsSettings.tsx
Normal file
510
src/Managing.WebApp/src/pages/adminPage/jobs/jobsSettings.tsx
Normal file
@@ -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<string>('CreatedAt')
|
||||||
|
const [sortOrder, setSortOrder] = useState<string>('desc')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('Pending')
|
||||||
|
const [jobTypeFilter, setJobTypeFilter] = useState<string>('')
|
||||||
|
const [userIdFilter, setUserIdFilter] = useState<string>('')
|
||||||
|
const [workerIdFilter, setWorkerIdFilter] = useState<string>('')
|
||||||
|
const [bundleRequestIdFilter, setBundleRequestIdFilter] = useState<string>('')
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState<boolean>(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 (
|
||||||
|
<div className="container mx-auto p-4 pb-20">
|
||||||
|
{/* Job Summary Statistics */}
|
||||||
|
<div className="mb-8">
|
||||||
|
{isLoadingSummary ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<progress className="progress progress-primary w-56"></progress>
|
||||||
|
<p className="mt-4 text-base-content/70">Loading job summary...</p>
|
||||||
|
</div>
|
||||||
|
) : jobSummary && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Overview Section */}
|
||||||
|
{jobSummary.statusSummary && jobSummary.statusSummary.length > 0 && (
|
||||||
|
<div className="card bg-base-100 shadow-md">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title text-xl mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||||
|
</svg>
|
||||||
|
Status Overview
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{jobSummary.statusSummary.map((statusItem) => {
|
||||||
|
const statusLower = (statusItem.status || '').toLowerCase()
|
||||||
|
let statusIcon, statusDesc, statusColor
|
||||||
|
|
||||||
|
switch (statusLower) {
|
||||||
|
case 'pending':
|
||||||
|
statusIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
statusDesc = 'Waiting to be processed'
|
||||||
|
statusColor = 'text-warning'
|
||||||
|
break
|
||||||
|
case 'running':
|
||||||
|
statusIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
statusDesc = 'Currently processing'
|
||||||
|
statusColor = 'text-info'
|
||||||
|
break
|
||||||
|
case 'completed':
|
||||||
|
statusIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
statusDesc = 'Successfully finished'
|
||||||
|
statusColor = 'text-success'
|
||||||
|
break
|
||||||
|
case 'failed':
|
||||||
|
statusIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
statusDesc = 'Requires attention'
|
||||||
|
statusColor = 'text-error'
|
||||||
|
break
|
||||||
|
case 'cancelled':
|
||||||
|
statusIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
statusDesc = 'Cancelled by user'
|
||||||
|
statusColor = 'text-neutral'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
statusIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
statusDesc = 'Unknown status'
|
||||||
|
statusColor = 'text-base-content'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={statusItem.status} className="card bg-base-200 shadow-sm">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="stat p-0">
|
||||||
|
<div className={`stat-figure ${statusColor}`}>
|
||||||
|
{statusIcon}
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">{statusItem.status || 'Unknown'}</div>
|
||||||
|
<div className={`stat-value ${statusColor}`}>{statusItem.count || 0}</div>
|
||||||
|
<div className="stat-desc">{statusDesc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Job Types Section */}
|
||||||
|
{jobSummary.jobTypeSummary && jobSummary.jobTypeSummary.length > 0 && (
|
||||||
|
<div className="card bg-base-100 shadow-md">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title text-xl mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
|
</svg>
|
||||||
|
Job Types
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{jobSummary.jobTypeSummary.map((typeItem) => {
|
||||||
|
const jobTypeLower = (typeItem.jobType || '').toLowerCase()
|
||||||
|
let jobTypeIcon, jobTypeDesc
|
||||||
|
|
||||||
|
switch (jobTypeLower) {
|
||||||
|
case 'backtest':
|
||||||
|
jobTypeIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232 1.232 3.228 0 4.46s-3.228 1.232-4.46 0L14.3 19.8M5 14.5l-1.402 1.402c-1.232 1.232-1.232 3.228 0 4.46s3.228 1.232 4.46 0L9.7 19.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
jobTypeDesc = 'Backtest jobs'
|
||||||
|
break
|
||||||
|
case 'geneticbacktest':
|
||||||
|
jobTypeIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
jobTypeDesc = 'Genetic backtest jobs'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
jobTypeIcon = (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
jobTypeDesc = 'Job type'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={typeItem.jobType} className="card bg-base-200 shadow-sm">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="stat p-0">
|
||||||
|
<div className="stat-figure text-primary">
|
||||||
|
{jobTypeIcon}
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">{typeItem.jobType || 'Unknown'}</div>
|
||||||
|
<div className="stat-value text-primary">{typeItem.count || 0}</div>
|
||||||
|
<div className="stat-desc">{jobTypeDesc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status by Job Type Table Section */}
|
||||||
|
{jobSummary.statusTypeSummary && jobSummary.statusTypeSummary.length > 0 && (
|
||||||
|
<div className="card bg-base-100 shadow-md">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title text-xl mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-3.75v3.75m-9 .75h12.75a2.25 2.25 0 002.25-2.25V6.75a2.25 2.25 0 00-2.25-2.25H6.75A2.25 2.25 0 004.5 6.75v7.5a2.25 2.25 0 002.25 2.25z" />
|
||||||
|
</svg>
|
||||||
|
Status by Job Type
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Job Type</th>
|
||||||
|
<th>Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobSummary.statusTypeSummary.map((item, index) => (
|
||||||
|
<tr key={`${item.status}-${item.jobType}-${index}`} className="hover">
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${getStatusBadgeColor(item.status)}`}>
|
||||||
|
{item.status || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{item.jobType || 'Unknown'}</td>
|
||||||
|
<td>{item.count || 0}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="card bg-base-200 mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Status</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
data-filter="status"
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value)
|
||||||
|
handleFilterChange()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="Pending">Pending</option>
|
||||||
|
<option value="Running">Running</option>
|
||||||
|
<option value="Completed">Completed</option>
|
||||||
|
<option value="Failed">Failed</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Job Type</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
data-filter="jobType"
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={jobTypeFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setJobTypeFilter(e.target.value)
|
||||||
|
handleFilterChange()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="Backtest">Backtest</option>
|
||||||
|
<option value="GeneticBacktest">Genetic Backtest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">User ID</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
data-filter="userId"
|
||||||
|
type="number"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="User ID"
|
||||||
|
value={userIdFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUserIdFilter(e.target.value)
|
||||||
|
handleFilterChange()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Worker ID</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
data-filter="workerId"
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Worker ID"
|
||||||
|
value={workerIdFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setWorkerIdFilter(e.target.value)
|
||||||
|
handleFilterChange()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Bundle Request ID</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
data-filter="bundleRequestId"
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
placeholder="Bundle Request ID"
|
||||||
|
value={bundleRequestIdFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBundleRequestIdFilter(e.target.value)
|
||||||
|
handleFilterChange()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error mb-4">
|
||||||
|
<span>Error loading jobs: {(error as any)?.message || 'Unknown error'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<JobsTable
|
||||||
|
jobs={jobs}
|
||||||
|
isLoading={isLoading}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageSize={pageSize}
|
||||||
|
sortBy={sortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom Menu Bar */}
|
||||||
|
<BottomMenuBar>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setFiltersOpen(!filtersOpen)
|
||||||
|
}}
|
||||||
|
className={filtersOpen ? 'active' : ''}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
clearFilters()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</BottomMenuBar>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JobsSettings
|
||||||
|
|
||||||
313
src/Managing.WebApp/src/pages/adminPage/jobs/jobsTable.tsx
Normal file
313
src/Managing.WebApp/src/pages/adminPage/jobs/jobsTable.tsx
Normal file
@@ -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<IJobsTable> = ({
|
||||||
|
jobs,
|
||||||
|
isLoading,
|
||||||
|
totalCount,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
onPageChange,
|
||||||
|
onSortChange
|
||||||
|
}) => {
|
||||||
|
const getStatusBadge = (status: string | null | undefined) => {
|
||||||
|
if (!status) return <span className="badge badge-sm">-</span>
|
||||||
|
|
||||||
|
const statusLower = status.toLowerCase()
|
||||||
|
switch (statusLower) {
|
||||||
|
case 'pending':
|
||||||
|
return <span className="badge badge-sm badge-warning">Pending</span>
|
||||||
|
case 'running':
|
||||||
|
return <span className="badge badge-sm badge-info">Running</span>
|
||||||
|
case 'completed':
|
||||||
|
return <span className="badge badge-sm badge-success">Completed</span>
|
||||||
|
case 'failed':
|
||||||
|
return <span className="badge badge-sm badge-error">Failed</span>
|
||||||
|
case 'cancelled':
|
||||||
|
return <span className="badge badge-sm badge-ghost">Cancelled</span>
|
||||||
|
default:
|
||||||
|
return <span className="badge badge-sm">{status}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJobTypeBadge = (jobType: string | null | undefined) => {
|
||||||
|
if (!jobType) return <span className="badge badge-sm">-</span>
|
||||||
|
|
||||||
|
const typeLower = jobType.toLowerCase()
|
||||||
|
switch (typeLower) {
|
||||||
|
case 'backtest':
|
||||||
|
return <span className="badge badge-sm badge-primary">Backtest</span>
|
||||||
|
case 'geneticbacktest':
|
||||||
|
return <span className="badge badge-sm badge-secondary">Genetic</span>
|
||||||
|
default:
|
||||||
|
return <span className="badge badge-sm">{jobType}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 cursor-pointer hover:text-primary"
|
||||||
|
onClick={() => onSortChange(column)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="text-xs">
|
||||||
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
Header: () => <SortableHeader column="JobId" label="Job ID" />,
|
||||||
|
accessor: 'jobId',
|
||||||
|
width: 200,
|
||||||
|
Cell: ({ value }: any) => (
|
||||||
|
<span className="font-mono text-xs">{value || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: () => <SortableHeader column="Status" label="Status" />,
|
||||||
|
accessor: 'status',
|
||||||
|
width: 120,
|
||||||
|
Cell: ({ value }: any) => getStatusBadge(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: () => <SortableHeader column="JobType" label="Job Type" />,
|
||||||
|
accessor: 'jobType',
|
||||||
|
width: 120,
|
||||||
|
Cell: ({ value }: any) => getJobTypeBadge(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: () => <SortableHeader column="Priority" label="Priority" />,
|
||||||
|
accessor: 'priority',
|
||||||
|
width: 100,
|
||||||
|
Cell: ({ value }: any) => (
|
||||||
|
<span className="font-bold">{value ?? 0}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: () => <SortableHeader column="ProgressPercentage" label="Progress" />,
|
||||||
|
accessor: 'progressPercentage',
|
||||||
|
width: 150,
|
||||||
|
Cell: ({ value }: any) => {
|
||||||
|
const percentage = value ?? 0
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<progress
|
||||||
|
className="progress progress-primary w-20"
|
||||||
|
value={percentage}
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{percentage}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'User ID',
|
||||||
|
accessor: 'userId',
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Worker ID',
|
||||||
|
accessor: 'assignedWorkerId',
|
||||||
|
width: 150,
|
||||||
|
Cell: ({ value }: any) => (
|
||||||
|
<span className="font-mono text-xs">{value || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Bundle Request ID',
|
||||||
|
accessor: 'bundleRequestId',
|
||||||
|
width: 200,
|
||||||
|
Cell: ({ value }: any) => (
|
||||||
|
<span className="font-mono text-xs">{value || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Genetic Request ID',
|
||||||
|
accessor: 'geneticRequestId',
|
||||||
|
width: 200,
|
||||||
|
Cell: ({ value }: any) => (
|
||||||
|
<span className="font-mono text-xs">{value || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: () => <SortableHeader column="CreatedAt" label="Created" />,
|
||||||
|
accessor: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
Cell: ({ value }: any) => formatDate(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: () => <SortableHeader column="StartedAt" label="Started" />,
|
||||||
|
accessor: 'startedAt',
|
||||||
|
width: 180,
|
||||||
|
Cell: ({ value }: any) => formatDate(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: () => <SortableHeader column="CompletedAt" label="Completed" />,
|
||||||
|
accessor: 'completedAt',
|
||||||
|
width: 180,
|
||||||
|
Cell: ({ value }: any) => formatDate(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Error Message',
|
||||||
|
accessor: 'errorMessage',
|
||||||
|
width: 300,
|
||||||
|
Cell: ({ value }: any) => (
|
||||||
|
<span className="text-xs text-error">{value || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Total jobs: {totalCount} | Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} | Page {currentPage} of {totalPages}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center my-4">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && jobs.length === 0 && (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
<span>No jobs found.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && jobs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={tableData}
|
||||||
|
showPagination={false}
|
||||||
|
hiddenColumns={[]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center items-center gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
{'<<'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
{'<'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page numbers */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
className={`btn btn-sm ${currentPage === pageNum ? 'btn-primary' : ''}`}
|
||||||
|
onClick={() => onPageChange(pageNum)}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
>
|
||||||
|
{'>'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
>
|
||||||
|
{'>>'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JobsTable
|
||||||
|
|
||||||
@@ -7,6 +7,73 @@ import {useEffect, useState} from 'react'
|
|||||||
import {useAuthStore} from '../../app/store/accountStore'
|
import {useAuthStore} from '../../app/store/accountStore'
|
||||||
import {ALLOWED_TICKERS, useClaimUiFees, useClaimUiFeesTransaction} from '../../hooks/useClaimUiFees'
|
import {ALLOWED_TICKERS, useClaimUiFees, useClaimUiFeesTransaction} from '../../hooks/useClaimUiFees'
|
||||||
import Toast from '../../components/mollecules/Toast/Toast'
|
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 (
|
||||||
|
<div className={`badge ${badgeColors[environment]} badge-sm font-semibold`}>
|
||||||
|
{badgeLabels[environment]}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<label tabIndex={0} className="cursor-pointer">
|
||||||
|
<EnvironmentBadge environment={environment} />
|
||||||
|
</label>
|
||||||
|
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
onClick={() => handleEnvironmentChange('local')}
|
||||||
|
className={environment === 'local' ? 'active' : ''}
|
||||||
|
>
|
||||||
|
Local
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
onClick={() => handleEnvironmentChange('sandbox')}
|
||||||
|
className={environment === 'sandbox' ? 'active' : ''}
|
||||||
|
>
|
||||||
|
Sandbox
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
onClick={() => handleEnvironmentChange('production')}
|
||||||
|
className={environment === 'production' ? 'active' : ''}
|
||||||
|
>
|
||||||
|
Production
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const Auth = ({ children }: any) => {
|
export const Auth = ({ children }: any) => {
|
||||||
const { getCookie, deleteCookie } = useCookie()
|
const { getCookie, deleteCookie } = useCookie()
|
||||||
@@ -90,6 +157,9 @@ export const Auth = ({ children }: any) => {
|
|||||||
deleteCookie('token')
|
deleteCookie('token')
|
||||||
return (
|
return (
|
||||||
<div style={{ ...styles, flexDirection: 'column', gap: '20px' }}>
|
<div style={{ ...styles, flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div style={{ position: 'absolute', top: '20px', right: '20px' }}>
|
||||||
|
<EnvironmentDropdown />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={login}
|
onClick={login}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, {useEffect, useRef, useState} from 'react';
|
|||||||
import {
|
import {
|
||||||
BacktestClient,
|
BacktestClient,
|
||||||
BundleBacktestRequest,
|
BundleBacktestRequest,
|
||||||
|
BundleBacktestStatusResponse,
|
||||||
BundleBacktestUniversalConfig,
|
BundleBacktestUniversalConfig,
|
||||||
DataClient,
|
DataClient,
|
||||||
DateTimeRange,
|
DateTimeRange,
|
||||||
@@ -23,6 +24,7 @@ import BacktestTable from '../../components/organism/Backtest/backtestTable';
|
|||||||
import FormInput from '../../components/mollecules/FormInput/FormInput';
|
import FormInput from '../../components/mollecules/FormInput/FormInput';
|
||||||
import CustomScenario from '../../components/organism/CustomScenario/CustomScenario';
|
import CustomScenario from '../../components/organism/CustomScenario/CustomScenario';
|
||||||
import {useCustomScenario} from '../../app/store/customScenario';
|
import {useCustomScenario} from '../../app/store/customScenario';
|
||||||
|
import {BottomMenuBar} from '../../components/mollecules';
|
||||||
|
|
||||||
interface BundleRequestModalProps {
|
interface BundleRequestModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -273,6 +275,21 @@ const BundleRequestModal: React.FC<BundleRequestModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bundle status query
|
||||||
|
const {
|
||||||
|
data: bundleStatus,
|
||||||
|
isLoading: isLoadingStatus,
|
||||||
|
error: statusError
|
||||||
|
} = useQuery<BundleBacktestStatusResponse>({
|
||||||
|
queryKey: ['bundle-status', bundle?.requestId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!open || !bundle?.requestId) return null;
|
||||||
|
return await backtestClient.backtest_GetBundleStatus(bundle.requestId);
|
||||||
|
},
|
||||||
|
enabled: !!open && !!bundle?.requestId,
|
||||||
|
refetchInterval: 5000, // Poll every 5 seconds for status updates
|
||||||
|
});
|
||||||
|
|
||||||
// Existing bundle viewing logic
|
// Existing bundle viewing logic
|
||||||
const {
|
const {
|
||||||
data: queryBacktests,
|
data: queryBacktests,
|
||||||
@@ -401,6 +418,54 @@ const BundleRequestModal: React.FC<BundleRequestModalProps> = ({
|
|||||||
<div><b>Created:</b> {bundle.createdAt ? new Date(bundle.createdAt).toLocaleString() : '-'}</div>
|
<div><b>Created:</b> {bundle.createdAt ? new Date(bundle.createdAt).toLocaleString() : '-'}</div>
|
||||||
<div><b>Completed:</b> {bundle.completedAt ? new Date(bundle.completedAt).toLocaleString() : '-'}</div>
|
<div><b>Completed:</b> {bundle.completedAt ? new Date(bundle.completedAt).toLocaleString() : '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bundle Status Display */}
|
||||||
|
{bundleStatus && (
|
||||||
|
<div className="mb-4 p-4 bg-base-200 rounded-lg">
|
||||||
|
<h4 className="font-semibold mb-2">Job Progress</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Total Jobs</div>
|
||||||
|
<div className="font-bold">{bundleStatus.totalJobs || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Pending</div>
|
||||||
|
<div className="font-bold text-warning">{bundleStatus.pendingJobs || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Running</div>
|
||||||
|
<div className="font-bold text-info">{bundleStatus.runningJobs || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Completed</div>
|
||||||
|
<div className="font-bold text-success">{bundleStatus.completedJobs || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Failed</div>
|
||||||
|
<div className="font-bold text-error">{bundleStatus.failedJobs || 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bundleStatus.progressPercentage !== undefined && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{bundleStatus.progressPercentage}%</span>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
className="progress progress-primary w-full"
|
||||||
|
value={bundleStatus.progressPercentage}
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bundleStatus.errorMessage && (
|
||||||
|
<div className="mt-2 text-error text-xs">
|
||||||
|
Error: {bundleStatus.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="divider">Backtest Results</div>
|
<div className="divider">Backtest Results</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div>Loading backtests...</div>
|
<div>Loading backtests...</div>
|
||||||
@@ -413,6 +478,56 @@ const BundleRequestModal: React.FC<BundleRequestModalProps> = ({
|
|||||||
<button className="btn" onClick={onClose}>Close</button>
|
<button className="btn" onClick={onClose}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Menu Bar */}
|
||||||
|
<BottomMenuBar>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Refresh">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Close">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</BottomMenuBar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -845,6 +960,86 @@ const BundleRequestModal: React.FC<BundleRequestModalProps> = ({
|
|||||||
<button className="btn" onClick={onClose}>Close</button>
|
<button className="btn" onClick={onClose}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Menu Bar */}
|
||||||
|
<BottomMenuBar>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Run Backtest">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleCreateBundle(false)
|
||||||
|
}}
|
||||||
|
className={!strategyName || selectedTickers.length === 0 || !scenario ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Save as Template">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleCreateBundle(true)
|
||||||
|
}}
|
||||||
|
className={!strategyName || selectedTickers.length === 0 || !scenario ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Close">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</BottomMenuBar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {ColorSwatchIcon, TrashIcon} from '@heroicons/react/solid'
|
|
||||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
import React, {useEffect, useState} from 'react'
|
import React, {useEffect, useState} from 'react'
|
||||||
|
|
||||||
import 'react-toastify/dist/ReactToastify.css'
|
import 'react-toastify/dist/ReactToastify.css'
|
||||||
import useApiUrlStore from '../../app/store/apiStore'
|
import useApiUrlStore from '../../app/store/apiStore'
|
||||||
import {Loader, Slider} from '../../components/atoms'
|
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 {BacktestTable, UnifiedTradingModal} from '../../components/organism'
|
||||||
import type {LightBacktestResponse} from '../../generated/ManagingApi'
|
import type {LightBacktestResponse} from '../../generated/ManagingApi'
|
||||||
import {BacktestClient, BacktestSortableColumn} from '../../generated/ManagingApi'
|
import {BacktestClient, BacktestSortableColumn} from '../../generated/ManagingApi'
|
||||||
@@ -25,6 +24,7 @@ const BacktestScanner: React.FC = () => {
|
|||||||
sortBy: BacktestSortableColumn.Score,
|
sortBy: BacktestSortableColumn.Score,
|
||||||
sortOrder: 'desc'
|
sortOrder: 'desc'
|
||||||
})
|
})
|
||||||
|
const [openFiltersTrigger, setOpenFiltersTrigger] = useState(0)
|
||||||
|
|
||||||
// Filters state coming from BacktestTable sidebar
|
// Filters state coming from BacktestTable sidebar
|
||||||
const [filters, setFilters] = useState<{
|
const [filters, setFilters] = useState<{
|
||||||
@@ -233,20 +233,7 @@ const BacktestScanner: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto pb-20">
|
||||||
<div className="tooltip" data-tip="Run backtest">
|
|
||||||
<button className="btn btn-secondary m-1 text-xs" onClick={openModal}>
|
|
||||||
<ColorSwatchIcon width="20"></ColorSwatchIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="tooltip" data-tip="Delete all Backtests inferior to 50%">
|
|
||||||
<button
|
|
||||||
className="btn btn-primary m-1 text-xs"
|
|
||||||
onClick={openModalRemoveBacktests}
|
|
||||||
>
|
|
||||||
<TrashIcon width="20"></TrashIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected filters summary */}
|
{/* Selected filters summary */}
|
||||||
<div className="mt-2 mb-2">
|
<div className="mt-2 mb-2">
|
||||||
@@ -302,6 +289,7 @@ const BacktestScanner: React.FC = () => {
|
|||||||
// Invalidate backtest queries when a backtest is deleted
|
// Invalidate backtest queries when a backtest is deleted
|
||||||
queryClient.invalidateQueries({ queryKey: ['backtests'] })
|
queryClient.invalidateQueries({ queryKey: ['backtests'] })
|
||||||
}}
|
}}
|
||||||
|
openFiltersTrigger={openFiltersTrigger}
|
||||||
/>
|
/>
|
||||||
{/* Pagination controls */}
|
{/* Pagination controls */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
@@ -406,6 +394,107 @@ const BacktestScanner: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Bottom Menu Bar */}
|
||||||
|
<BottomMenuBar>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Show Filters">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpenFiltersTrigger(prev => prev + 1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Run Backtest">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
openModal()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Delete Backtests by Filters">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
openModalRemoveBacktests()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Refresh">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</BottomMenuBar>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Table from '../../components/mollecules/Table/Table';
|
|||||||
import {BundleBacktestRequest} from '../../generated/ManagingApiTypes';
|
import {BundleBacktestRequest} from '../../generated/ManagingApiTypes';
|
||||||
import Toast from '../../components/mollecules/Toast/Toast';
|
import Toast from '../../components/mollecules/Toast/Toast';
|
||||||
import BundleRequestModal from './BundleRequestModal';
|
import BundleRequestModal from './BundleRequestModal';
|
||||||
|
import {BottomMenuBar} from '../../components/mollecules';
|
||||||
|
|
||||||
const BundleRequestsTable = () => {
|
const BundleRequestsTable = () => {
|
||||||
const { apiUrl } = useApiUrlStore();
|
const { apiUrl } = useApiUrlStore();
|
||||||
@@ -154,19 +155,7 @@ const BundleRequestsTable = () => {
|
|||||||
if (error) return <div className="text-error">{error}</div>;
|
if (error) return <div className="text-error">{error}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full pb-20">
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-lg font-bold">Bundle Backtest Requests</h2>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedBundle(null);
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create New Bundle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Table columns={columns} data={data} showPagination={true} />
|
<Table columns={columns} data={data} showPagination={true} />
|
||||||
<BundleRequestModal
|
<BundleRequestModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
@@ -178,6 +167,57 @@ const BundleRequestsTable = () => {
|
|||||||
fetchData(); // Refresh the table
|
fetchData(); // Refresh the table
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Bottom Menu Bar */}
|
||||||
|
<BottomMenuBar>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Create New Bundle">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedBundle(null)
|
||||||
|
setModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="tooltip tooltip-top" data-tip="Refresh">
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
fetchData()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</BottomMenuBar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,70 +115,104 @@ function UserInfoSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<div className="bg-base-200 rounded-lg p-6 shadow-lg">
|
<div className="bg-base-200 rounded-lg p-6 shadow-lg">
|
||||||
<h2 className="text-2xl font-bold mb-4">User Information</h2>
|
<h2 className="text-2xl font-bold mb-6 text-base-content">User Information</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
{/* Name Card */}
|
||||||
<label className="font-semibold">Name:</label>
|
<div className="card bg-base-100 shadow-md">
|
||||||
<p>{user?.name}</p>
|
<div className="card-body">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="card-title text-sm font-semibold text-base-content/70">Name</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-medium text-base-content">{user?.name || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Agent Name Card */}
|
||||||
<label className="font-semibold">Agent Name:</label>
|
<div className="card bg-base-100 shadow-md">
|
||||||
<p>{user?.agentName || 'Not set'}</p>
|
<div className="card-body">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="card-title text-sm font-semibold text-base-content/70">Agent Name</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className={`text-lg font-medium flex-1 ${user?.agentName ? 'text-base-content' : 'text-base-content/50'}`}>
|
||||||
|
{user?.agentName || 'Not set'}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary mt-2"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => setShowUpdateModal(true)}
|
onClick={() => setShowUpdateModal(true)}
|
||||||
>
|
>
|
||||||
Update Agent Name
|
Update
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Avatar Card */}
|
||||||
<label className="font-semibold">Avatar:</label>
|
<div className="card bg-base-100 shadow-md">
|
||||||
<div className="mt-2 flex items-center space-x-4">
|
<div className="card-body">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="card-title text-sm font-semibold text-base-content/70">Avatar</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
{user?.avatarUrl ? (
|
{user?.avatarUrl ? (
|
||||||
|
<div className="avatar">
|
||||||
|
<div className="w-20 h-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||||
<img
|
<img
|
||||||
src={user.avatarUrl}
|
src={user.avatarUrl}
|
||||||
alt="User avatar"
|
alt="User avatar"
|
||||||
className="w-16 h-16 rounded-full object-cover"
|
className="rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-base-300 flex items-center justify-center">
|
<div className="avatar placeholder">
|
||||||
<span className="text-2xl">{user?.name?.[0]?.toUpperCase() || '?'}</span>
|
<div className="w-20 h-20 rounded-full bg-neutral text-neutral-content ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||||
|
<span className="text-3xl font-bold">{user?.name?.[0]?.toUpperCase() || '?'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => setShowAvatarModal(true)}
|
onClick={() => setShowAvatarModal(true)}
|
||||||
>
|
>
|
||||||
Update Avatar
|
Update Avatar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Telegram Channel Card */}
|
||||||
<label className="font-semibold">Telegram Channel:</label>
|
<div className="card bg-base-100 shadow-md">
|
||||||
<p>{user?.telegramChannel || 'Not set'}</p>
|
<div className="card-body">
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="card-title text-sm font-semibold text-base-content/70">Telegram Channel</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<p className={`text-lg font-medium flex-1 ${user?.telegramChannel ? 'text-base-content' : 'text-base-content/50'}`}>
|
||||||
|
{user?.telegramChannel || 'Not set'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-sm flex-1"
|
||||||
onClick={() => setShowTelegramModal(true)}
|
onClick={() => setShowTelegramModal(true)}
|
||||||
>
|
>
|
||||||
Update Telegram Channel
|
Update Channel
|
||||||
</button>
|
</button>
|
||||||
{user?.telegramChannel && (
|
{user?.telegramChannel && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={testTelegramChannel}
|
onClick={testTelegramChannel}
|
||||||
>
|
>
|
||||||
Test Channel
|
Test
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
showModal={showUpdateModal}
|
showModal={showUpdateModal}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {Tabs} from '../../components/mollecules'
|
|||||||
|
|
||||||
import MoneyManagementSettings from './moneymanagement/moneyManagement'
|
import MoneyManagementSettings from './moneymanagement/moneyManagement'
|
||||||
import Theme from './theme'
|
import Theme from './theme'
|
||||||
import DefaultConfig from './defaultConfig/defaultConfig'
|
|
||||||
import UserInfoSettings from './UserInfoSettings'
|
import UserInfoSettings from './UserInfoSettings'
|
||||||
import AccountFee from './accountFee/accountFee'
|
import AccountFee from './accountFee/accountFee'
|
||||||
|
|
||||||
@@ -36,11 +35,6 @@ const tabs: TabsType = [
|
|||||||
index: 4,
|
index: 4,
|
||||||
label: 'Theme',
|
label: 'Theme',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Component: DefaultConfig,
|
|
||||||
index: 5,
|
|
||||||
label: 'Quick Start Config',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const Settings: React.FC = () => {
|
const Settings: React.FC = () => {
|
||||||
|
|||||||
39
src/Managing.Workers.Api/Dockerfile
Normal file
39
src/Managing.Workers.Api/Dockerfile
Normal file
@@ -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"]
|
||||||
|
|
||||||
26
src/Managing.Workers.Api/Managing.Workers.Api.csproj
Normal file
26
src/Managing.Workers.Api/Managing.Workers.Api.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-Managing.Workers.Api-ff3f3987-4da4-4140-9180-b84c9e07b25f</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1"/>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11"/>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10"/>
|
||||||
|
<PackageReference Include="Sentry" Version="5.5.1"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Core" Version="1.1.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Managing.Bootstrap\Managing.Bootstrap.csproj"/>
|
||||||
|
<ProjectReference Include="..\Managing.Application\Managing.Application.csproj"/>
|
||||||
|
<ProjectReference Include="..\Managing.Application.Abstractions\Managing.Application.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\Managing.Infrastructure.Database\Managing.Infrastructure.Databases.csproj"/>
|
||||||
|
<ProjectReference Include="..\Managing.Infrastructure.Exchanges\Managing.Infrastructure.Exchanges.csproj"/>
|
||||||
|
<ProjectReference Include="..\Managing.Common\Managing.Common.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
119
src/Managing.Workers.Api/Program.cs
Normal file
119
src/Managing.Workers.Api/Program.cs
Normal file
@@ -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<Program>();
|
||||||
|
})
|
||||||
|
.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<PostgreSqlSettings>(configuration.GetSection(Constants.Databases.PostgreSql));
|
||||||
|
services.Configure<InfluxDbSettings>(configuration.GetSection(Constants.Databases.InfluxDb));
|
||||||
|
|
||||||
|
// Add DbContext
|
||||||
|
services.AddDbContext<ManagingDbContext>((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<BacktestComputeWorkerOptions>(
|
||||||
|
configuration.GetSection(BacktestComputeWorkerOptions.SectionName));
|
||||||
|
|
||||||
|
// Override WorkerId from environment variable if provided
|
||||||
|
var workerId = Environment.GetEnvironmentVariable("WORKER_ID") ??
|
||||||
|
configuration["BacktestComputeWorker:WorkerId"] ??
|
||||||
|
Environment.MachineName;
|
||||||
|
services.Configure<BacktestComputeWorkerOptions>(options =>
|
||||||
|
{
|
||||||
|
options.WorkerId = workerId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the compute worker if enabled
|
||||||
|
var isWorkerEnabled = configuration.GetValue<bool>("WorkerBacktestCompute", false);
|
||||||
|
if (isWorkerEnabled)
|
||||||
|
{
|
||||||
|
services.AddHostedService<BacktestComputeWorker>();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ConfigureLogging((hostingContext, logging) =>
|
||||||
|
{
|
||||||
|
logging.ClearProviders();
|
||||||
|
logging.AddConsole();
|
||||||
|
logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"));
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Log worker status
|
||||||
|
var logger = host.Services.GetRequiredService<ILogger<Program>>();
|
||||||
|
var isWorkerEnabled = host.Services.GetRequiredService<IConfiguration>().GetValue<bool>("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<IConfiguration>()["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();
|
||||||
|
}
|
||||||
24
src/Managing.Workers.Api/Worker.cs
Normal file
24
src/Managing.Workers.Api/Worker.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Managing.Workers.Api;
|
||||||
|
|
||||||
|
public class Worker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<Worker> _logger;
|
||||||
|
|
||||||
|
public Worker(ILogger<Worker> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Managing.Workers.Api/appsettings.Development.json
Normal file
27
src/Managing.Workers.Api/appsettings.Development.json
Normal file
@@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Managing.Workers.Api/appsettings.json
Normal file
38
src/Managing.Workers.Api/appsettings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/Managing.Workers.Api/captain-definition
Normal file
5
src/Managing.Workers.Api/captain-definition
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"dockerfilePath": "../Dockerfile-worker-api-dev"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -68,6 +68,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Managing.Aspire.ServiceDefa
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Managing.Nswag", "Managing.Nswag\Managing.Nswag.csproj", "{BE50F950-C1D4-4CE0-B32E-6AAC996770D5}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Managing.Nswag", "Managing.Nswag\Managing.Nswag.csproj", "{BE50F950-C1D4-4CE0-B32E-6AAC996770D5}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Managing.Workers.Api", "Managing.Workers.Api\Managing.Workers.Api.csproj", "{B7D66A73-CA3A-4DE5-8E88-59D50C4018A6}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|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.ActiveCfg = Release|Any CPU
|
||||||
{BE50F950-C1D4-4CE0-B32E-6AAC996770D5}.Release|x64.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -258,6 +268,7 @@ Global
|
|||||||
{A1D88DC3-1CF6-4C03-AEEC-30AA37420CE1} = {D6711C71-A263-4398-8DFF-28E2CD1FE0CE}
|
{A1D88DC3-1CF6-4C03-AEEC-30AA37420CE1} = {D6711C71-A263-4398-8DFF-28E2CD1FE0CE}
|
||||||
{4521E1A9-AF81-4CA8-8B4D-30C261ECE977} = {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}
|
{BE50F950-C1D4-4CE0-B32E-6AAC996770D5} = {D6711C71-A263-4398-8DFF-28E2CD1FE0CE}
|
||||||
|
{B7D66A73-CA3A-4DE5-8E88-59D50C4018A6} = {A1296069-2816-43D4-882C-516BCB718D03}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {BD7CA081-CE52-4824-9777-C0562E54F3EA}
|
SolutionGuid = {BD7CA081-CE52-4824-9777-C0562E54F3EA}
|
||||||
|
|||||||
Reference in New Issue
Block a user