LEARNING INTEGRATION: - Enhanced AI analysis service feeds historical data into OpenAI prompts - Symbol/timeframe specific learning optimization - Pattern recognition from past trade outcomes - Confidence adjustment based on success rates HTTP COMPATIBILITY SYSTEM: - HttpUtil with automatic curl/no-curl detection - Node.js fallback for Docker environments without curl - Updated all automation systems to use HttpUtil - Production-ready error handling AUTONOMOUS RISK MANAGEMENT: - Enhanced risk manager with learning integration - Simplified learners using existing AILearningData schema - Real-time position monitoring every 30 seconds - Smart stop-loss decisions with AI learning INFRASTRUCTURE: - Database utility for shared Prisma connections - Beach mode status display system - Complete error handling and recovery - Docker container compatibility tested Historical performance flows into OpenAI prompts before every trade.
600 lines
20 KiB
JavaScript
600 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Stop Loss Decision Learning System
|
|
*
|
|
* This system makes the AI learn from its own decision-making process near stop loss.
|
|
* It records every decision, tracks outcomes, and continuously improves decision-making.
|
|
*/
|
|
|
|
const { getDB } = require('./db');
|
|
|
|
class StopLossDecisionLearner {
|
|
constructor() {
|
|
this.decisionHistory = [];
|
|
this.learningThresholds = {
|
|
emergencyDistance: 1.0,
|
|
highRiskDistance: 2.0,
|
|
mediumRiskDistance: 5.0
|
|
};
|
|
}
|
|
|
|
async getPrisma() {
|
|
return await getDB();
|
|
}
|
|
|
|
async log(message) {
|
|
const timestamp = new Date().toISOString();
|
|
console.log(`[${timestamp}] 🧠 SL Learner: ${message}`);
|
|
}
|
|
|
|
/**
|
|
* Record an AI decision made near stop loss for learning purposes
|
|
*/
|
|
async recordDecision(decisionData) {
|
|
try {
|
|
const decision = {
|
|
id: `decision_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
tradeId: decisionData.tradeId,
|
|
symbol: decisionData.symbol,
|
|
decisionType: decisionData.decision, // 'HOLD', 'EXIT', 'TIGHTEN_SL', 'PARTIAL_EXIT', 'EMERGENCY_EXIT'
|
|
distanceFromSL: decisionData.distanceFromSL,
|
|
reasoning: decisionData.reasoning,
|
|
marketConditions: {
|
|
price: decisionData.currentPrice,
|
|
trend: await this.analyzeMarketTrend(decisionData.symbol),
|
|
volatility: await this.calculateVolatility(decisionData.symbol),
|
|
volume: decisionData.volume || 'unknown',
|
|
timeOfDay: new Date().getHours(),
|
|
dayOfWeek: new Date().getDay()
|
|
},
|
|
confidenceScore: decisionData.confidenceScore || 0.7,
|
|
expectedOutcome: decisionData.expectedOutcome || 'BETTER_RESULT',
|
|
decisionTimestamp: new Date(),
|
|
status: 'PENDING_OUTCOME'
|
|
};
|
|
|
|
// Store in database
|
|
const prisma = await this.getPrisma();
|
|
await prisma.sLDecision.create({
|
|
data: {
|
|
id: decision.id,
|
|
tradeId: decision.tradeId,
|
|
symbol: decision.symbol,
|
|
decisionType: decision.decisionType,
|
|
distanceFromSL: decision.distanceFromSL,
|
|
reasoning: decision.reasoning,
|
|
marketConditions: JSON.stringify(decision.marketConditions),
|
|
confidenceScore: decision.confidenceScore,
|
|
expectedOutcome: decision.expectedOutcome,
|
|
decisionTimestamp: decision.decisionTimestamp,
|
|
status: decision.status
|
|
}
|
|
});
|
|
|
|
// Keep in memory for quick access
|
|
this.decisionHistory.push(decision);
|
|
|
|
await this.log(`📝 Recorded decision: ${decision.decisionType} at ${decision.distanceFromSL}% from SL - ${decision.reasoning}`);
|
|
|
|
return decision.id;
|
|
} catch (error) {
|
|
await this.log(`❌ Error recording decision: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assess the outcome of a previous decision when trade closes or conditions change
|
|
*/
|
|
async assessDecisionOutcome(assessmentData) {
|
|
try {
|
|
const { decisionId, actualOutcome, timeToOutcome, pnlImpact, additionalContext } = assessmentData;
|
|
|
|
// Determine if the decision was correct
|
|
const wasCorrect = this.evaluateDecisionCorrectness(actualOutcome, pnlImpact);
|
|
const learningScore = this.calculateLearningScore(wasCorrect, pnlImpact, timeToOutcome);
|
|
|
|
// Update decision record
|
|
const prisma = await this.getPrisma();
|
|
await prisma.sLDecision.update({
|
|
where: { id: decisionId },
|
|
data: {
|
|
outcome: actualOutcome,
|
|
outcomeTimestamp: new Date(),
|
|
timeToOutcome,
|
|
pnlImpact,
|
|
wasCorrect,
|
|
learningScore,
|
|
additionalContext: JSON.stringify(additionalContext || {}),
|
|
status: 'ASSESSED'
|
|
}
|
|
});
|
|
|
|
// Update in-memory history
|
|
const decision = this.decisionHistory.find(d => d.id === decisionId);
|
|
if (decision) {
|
|
Object.assign(decision, {
|
|
outcome: actualOutcome,
|
|
outcomeTimestamp: new Date(),
|
|
wasCorrect,
|
|
learningScore,
|
|
status: 'ASSESSED'
|
|
});
|
|
}
|
|
|
|
await this.log(`✅ Assessed decision ${decisionId}: ${wasCorrect ? 'CORRECT' : 'INCORRECT'} - Score: ${learningScore.toFixed(2)}`);
|
|
|
|
// Trigger learning update
|
|
await this.updateLearningModel();
|
|
|
|
return { wasCorrect, learningScore };
|
|
} catch (error) {
|
|
await this.log(`❌ Error assessing decision outcome: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Analyze historical decisions to identify patterns and optimize future decisions
|
|
*/
|
|
async analyzeDecisionPatterns() {
|
|
try {
|
|
const prisma = await this.getPrisma();
|
|
const decisions = await prisma.sLDecision.findMany({
|
|
where: { status: 'ASSESSED' },
|
|
orderBy: { decisionTimestamp: 'desc' },
|
|
take: 100 // Analyze last 100 decisions
|
|
});
|
|
|
|
const patterns = {
|
|
successfulPatterns: [],
|
|
failurePatterns: [],
|
|
optimalTiming: {},
|
|
contextFactors: {},
|
|
distanceOptimization: {}
|
|
};
|
|
|
|
// Analyze success patterns by decision type
|
|
const decisionTypes = ['HOLD', 'EXIT', 'TIGHTEN_SL', 'PARTIAL_EXIT', 'EMERGENCY_EXIT'];
|
|
|
|
for (const type of decisionTypes) {
|
|
const typeDecisions = decisions.filter(d => d.decisionType === type);
|
|
const successRate = typeDecisions.length > 0 ?
|
|
typeDecisions.filter(d => d.wasCorrect).length / typeDecisions.length : 0;
|
|
|
|
const avgScore = typeDecisions.length > 0 ?
|
|
typeDecisions.reduce((sum, d) => sum + (d.learningScore || 0), 0) / typeDecisions.length : 0;
|
|
|
|
if (successRate > 0.6) { // 60%+ success rate
|
|
patterns.successfulPatterns.push({
|
|
decisionType: type,
|
|
successRate: successRate * 100,
|
|
avgScore,
|
|
sampleSize: typeDecisions.length,
|
|
optimalConditions: this.identifyOptimalConditions(typeDecisions.filter(d => d.wasCorrect))
|
|
});
|
|
} else if (typeDecisions.length >= 5) {
|
|
patterns.failurePatterns.push({
|
|
decisionType: type,
|
|
successRate: successRate * 100,
|
|
avgScore,
|
|
sampleSize: typeDecisions.length,
|
|
commonFailureReasons: this.identifyFailureReasons(typeDecisions.filter(d => !d.wasCorrect))
|
|
});
|
|
}
|
|
}
|
|
|
|
// Analyze optimal distance thresholds
|
|
patterns.distanceOptimization = await this.optimizeDistanceThresholds(decisions);
|
|
|
|
// Analyze timing patterns
|
|
patterns.optimalTiming = await this.analyzeTimingPatterns(decisions);
|
|
|
|
await this.log(`📊 Pattern analysis complete: ${patterns.successfulPatterns.length} successful patterns, ${patterns.failurePatterns.length} failure patterns identified`);
|
|
|
|
return patterns;
|
|
} catch (error) {
|
|
await this.log(`❌ Error analyzing decision patterns: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get AI recommendation for current situation based on learned patterns
|
|
*/
|
|
async getSmartRecommendation(situationData) {
|
|
try {
|
|
const { distanceFromSL, symbol, marketConditions } = situationData;
|
|
|
|
// Get historical patterns for similar situations
|
|
const patterns = await this.analyzeDecisionPatterns();
|
|
const currentConditions = marketConditions || await this.getCurrentMarketConditions(symbol);
|
|
|
|
// Find most similar historical situations
|
|
const similarSituations = await this.findSimilarSituations({
|
|
distanceFromSL,
|
|
marketConditions: currentConditions
|
|
});
|
|
|
|
// Generate recommendation based on learned patterns
|
|
const recommendation = {
|
|
suggestedAction: 'HOLD', // Default
|
|
confidence: 0.5,
|
|
reasoning: 'Insufficient learning data',
|
|
learningBased: false,
|
|
supportingData: {}
|
|
};
|
|
|
|
if (similarSituations.length >= 3) {
|
|
const successfulActions = similarSituations
|
|
.filter(s => s.wasCorrect)
|
|
.map(s => s.decisionType);
|
|
|
|
const mostSuccessfulAction = this.getMostCommonAction(successfulActions);
|
|
const successRate = successfulActions.length / similarSituations.length;
|
|
|
|
recommendation.suggestedAction = mostSuccessfulAction;
|
|
recommendation.confidence = Math.min(0.95, successRate + 0.1);
|
|
recommendation.reasoning = `Based on ${similarSituations.length} similar situations, ${mostSuccessfulAction} succeeded ${(successRate * 100).toFixed(1)}% of the time`;
|
|
recommendation.learningBased = true;
|
|
recommendation.supportingData = {
|
|
historicalSamples: similarSituations.length,
|
|
successRate: successRate * 100,
|
|
avgPnlImpact: similarSituations.reduce((sum, s) => sum + (s.pnlImpact || 0), 0) / similarSituations.length
|
|
};
|
|
}
|
|
|
|
await this.log(`🎯 Smart recommendation: ${recommendation.suggestedAction} (${(recommendation.confidence * 100).toFixed(1)}% confidence) - ${recommendation.reasoning}`);
|
|
|
|
return recommendation;
|
|
} catch (error) {
|
|
await this.log(`❌ Error generating smart recommendation: ${error.message}`);
|
|
return {
|
|
suggestedAction: 'HOLD',
|
|
confidence: 0.3,
|
|
reasoning: `Error in recommendation system: ${error.message}`,
|
|
learningBased: false
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update learning model based on new decision outcomes
|
|
*/
|
|
async updateLearningModel() {
|
|
try {
|
|
const patterns = await this.analyzeDecisionPatterns();
|
|
|
|
if (patterns && patterns.distanceOptimization) {
|
|
// Update decision thresholds based on learning
|
|
this.learningThresholds = {
|
|
emergencyDistance: patterns.distanceOptimization.optimalEmergencyThreshold || 1.0,
|
|
highRiskDistance: patterns.distanceOptimization.optimalHighRiskThreshold || 2.0,
|
|
mediumRiskDistance: patterns.distanceOptimization.optimalMediumRiskThreshold || 5.0
|
|
};
|
|
|
|
await this.log(`🔄 Updated learning thresholds: Emergency=${this.learningThresholds.emergencyDistance}%, High Risk=${this.learningThresholds.highRiskDistance}%, Medium Risk=${this.learningThresholds.mediumRiskDistance}%`);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
await this.log(`❌ Error updating learning model: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper methods for analysis
|
|
*/
|
|
evaluateDecisionCorrectness(actualOutcome, pnlImpact) {
|
|
// Define what constitutes a "correct" decision
|
|
const correctOutcomes = [
|
|
'BETTER_THAN_ORIGINAL_SL',
|
|
'AVOIDED_LOSS',
|
|
'IMPROVED_PROFIT',
|
|
'SUCCESSFUL_EXIT'
|
|
];
|
|
|
|
return correctOutcomes.includes(actualOutcome) || (pnlImpact && pnlImpact > 0);
|
|
}
|
|
|
|
calculateLearningScore(wasCorrect, pnlImpact, timeToOutcome) {
|
|
let score = wasCorrect ? 0.7 : 0.3; // Base score
|
|
|
|
// Adjust for P&L impact
|
|
if (pnlImpact) {
|
|
score += Math.min(0.2, pnlImpact / 100); // Max 0.2 bonus for positive P&L
|
|
}
|
|
|
|
// Adjust for timing (faster good decisions are better)
|
|
if (timeToOutcome && wasCorrect) {
|
|
const timingBonus = Math.max(0, 0.1 - (timeToOutcome / 3600)); // Bonus for decisions resolved within an hour
|
|
score += timingBonus;
|
|
}
|
|
|
|
return Math.max(0, Math.min(1, score));
|
|
}
|
|
|
|
identifyOptimalConditions(successfulDecisions) {
|
|
// Analyze common conditions in successful decisions
|
|
const conditions = {};
|
|
|
|
successfulDecisions.forEach(decision => {
|
|
try {
|
|
const market = JSON.parse(decision.marketConditions || '{}');
|
|
|
|
// Track successful decision contexts
|
|
if (market.trend) {
|
|
conditions.trend = conditions.trend || {};
|
|
conditions.trend[market.trend] = (conditions.trend[market.trend] || 0) + 1;
|
|
}
|
|
|
|
if (market.timeOfDay) {
|
|
conditions.timeOfDay = conditions.timeOfDay || {};
|
|
const hour = market.timeOfDay;
|
|
conditions.timeOfDay[hour] = (conditions.timeOfDay[hour] || 0) + 1;
|
|
}
|
|
} catch (error) {
|
|
// Skip malformed data
|
|
}
|
|
});
|
|
|
|
return conditions;
|
|
}
|
|
|
|
identifyFailureReasons(failedDecisions) {
|
|
// Analyze what went wrong in failed decisions
|
|
return failedDecisions.map(decision => ({
|
|
reasoning: decision.reasoning,
|
|
distanceFromSL: decision.distanceFromSL,
|
|
outcome: decision.outcome,
|
|
pnlImpact: decision.pnlImpact
|
|
}));
|
|
}
|
|
|
|
async optimizeDistanceThresholds(decisions) {
|
|
// Analyze optimal distance thresholds for different decision types
|
|
const optimization = {};
|
|
|
|
// Group decisions by distance ranges
|
|
const ranges = [
|
|
{ min: 0, max: 1, label: 'emergency' },
|
|
{ min: 1, max: 2, label: 'highRisk' },
|
|
{ min: 2, max: 5, label: 'mediumRisk' },
|
|
{ min: 5, max: 100, label: 'safe' }
|
|
];
|
|
|
|
for (const range of ranges) {
|
|
const rangeDecisions = decisions.filter(d =>
|
|
d.distanceFromSL >= range.min && d.distanceFromSL < range.max
|
|
);
|
|
|
|
if (rangeDecisions.length >= 3) {
|
|
const successRate = rangeDecisions.filter(d => d.wasCorrect).length / rangeDecisions.length;
|
|
const avgScore = rangeDecisions.reduce((sum, d) => sum + (d.learningScore || 0), 0) / rangeDecisions.length;
|
|
|
|
optimization[`${range.label}Range`] = {
|
|
successRate: successRate * 100,
|
|
avgScore,
|
|
sampleSize: rangeDecisions.length,
|
|
optimalThreshold: this.calculateOptimalThreshold(rangeDecisions)
|
|
};
|
|
}
|
|
}
|
|
|
|
return optimization;
|
|
}
|
|
|
|
calculateOptimalThreshold(decisions) {
|
|
// Find the distance threshold that maximizes success rate
|
|
const sortedDecisions = decisions.sort((a, b) => a.distanceFromSL - b.distanceFromSL);
|
|
let bestThreshold = 1.0;
|
|
let bestScore = 0;
|
|
|
|
for (let i = 0; i < sortedDecisions.length - 1; i++) {
|
|
const threshold = sortedDecisions[i].distanceFromSL;
|
|
const aboveThreshold = sortedDecisions.slice(i);
|
|
const successRate = aboveThreshold.filter(d => d.wasCorrect).length / aboveThreshold.length;
|
|
|
|
if (successRate > bestScore && aboveThreshold.length >= 3) {
|
|
bestScore = successRate;
|
|
bestThreshold = threshold;
|
|
}
|
|
}
|
|
|
|
return bestThreshold;
|
|
}
|
|
|
|
async analyzeTimingPatterns(decisions) {
|
|
// Analyze when decisions work best (time of day, day of week, etc.)
|
|
const timing = {
|
|
timeOfDay: {},
|
|
dayOfWeek: {},
|
|
marketSession: {}
|
|
};
|
|
|
|
decisions.forEach(decision => {
|
|
try {
|
|
const market = JSON.parse(decision.marketConditions || '{}');
|
|
const wasCorrect = decision.wasCorrect;
|
|
|
|
if (market.timeOfDay !== undefined) {
|
|
const hour = market.timeOfDay;
|
|
timing.timeOfDay[hour] = timing.timeOfDay[hour] || { total: 0, correct: 0 };
|
|
timing.timeOfDay[hour].total++;
|
|
if (wasCorrect) timing.timeOfDay[hour].correct++;
|
|
}
|
|
|
|
if (market.dayOfWeek !== undefined) {
|
|
const day = market.dayOfWeek;
|
|
timing.dayOfWeek[day] = timing.dayOfWeek[day] || { total: 0, correct: 0 };
|
|
timing.dayOfWeek[day].total++;
|
|
if (wasCorrect) timing.dayOfWeek[day].correct++;
|
|
}
|
|
} catch (error) {
|
|
// Skip malformed data
|
|
}
|
|
});
|
|
|
|
return timing;
|
|
}
|
|
|
|
async findSimilarSituations(currentSituation) {
|
|
const { distanceFromSL, marketConditions } = currentSituation;
|
|
const tolerance = 0.5; // 0.5% tolerance for distance matching
|
|
|
|
const prisma = await this.getPrisma();
|
|
const decisions = await prisma.sLDecision.findMany({
|
|
where: {
|
|
status: 'ASSESSED',
|
|
distanceFromSL: {
|
|
gte: distanceFromSL - tolerance,
|
|
lte: distanceFromSL + tolerance
|
|
}
|
|
},
|
|
orderBy: { decisionTimestamp: 'desc' },
|
|
take: 20
|
|
});
|
|
|
|
return decisions;
|
|
}
|
|
|
|
getMostCommonAction(actions) {
|
|
const counts = {};
|
|
actions.forEach(action => {
|
|
counts[action] = (counts[action] || 0) + 1;
|
|
});
|
|
|
|
return Object.entries(counts).reduce((a, b) => counts[a] > counts[b] ? a : b)[0] || 'HOLD';
|
|
}
|
|
|
|
async analyzeMarketTrend(symbol) {
|
|
// Simplified trend analysis - in real implementation, use technical indicators
|
|
try {
|
|
const response = await fetch(`http://localhost:9001/api/automation/position-monitor`);
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.monitor && data.monitor.position) {
|
|
const pnl = data.monitor.position.unrealizedPnl;
|
|
if (pnl > 0) return 'BULLISH';
|
|
if (pnl < 0) return 'BEARISH';
|
|
return 'SIDEWAYS';
|
|
}
|
|
} catch (error) {
|
|
// Fallback
|
|
}
|
|
|
|
return 'UNKNOWN';
|
|
}
|
|
|
|
async calculateVolatility(symbol) {
|
|
// Simplified volatility calculation
|
|
// In real implementation, calculate based on price history
|
|
return Math.random() * 0.1; // Mock volatility 0-10%
|
|
}
|
|
|
|
async getCurrentMarketConditions(symbol) {
|
|
return {
|
|
trend: await this.analyzeMarketTrend(symbol),
|
|
volatility: await this.calculateVolatility(symbol),
|
|
timeOfDay: new Date().getHours(),
|
|
dayOfWeek: new Date().getDay()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate learning insights report
|
|
*/
|
|
async generateLearningReport() {
|
|
try {
|
|
const patterns = await this.analyzeDecisionPatterns();
|
|
|
|
const report = {
|
|
timestamp: new Date().toISOString(),
|
|
summary: {
|
|
totalDecisions: this.decisionHistory.length,
|
|
successfulPatterns: patterns?.successfulPatterns?.length || 0,
|
|
learningThresholds: this.learningThresholds,
|
|
systemConfidence: this.calculateSystemConfidence()
|
|
},
|
|
insights: patterns,
|
|
recommendations: await this.generateSystemRecommendations(patterns)
|
|
};
|
|
|
|
await this.log(`📊 Learning report generated: ${report.summary.totalDecisions} decisions analyzed`);
|
|
|
|
return report;
|
|
} catch (error) {
|
|
await this.log(`❌ Error generating learning report: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
calculateSystemConfidence() {
|
|
const recentDecisions = this.decisionHistory.slice(-20); // Last 20 decisions
|
|
if (recentDecisions.length < 5) return 0.3; // Low confidence with insufficient data
|
|
|
|
const successRate = recentDecisions.filter(d => d.wasCorrect).length / recentDecisions.length;
|
|
return Math.min(0.95, successRate + 0.1); // Cap at 95%
|
|
}
|
|
|
|
async generateSystemRecommendations(patterns) {
|
|
const recommendations = [];
|
|
|
|
if (patterns?.failurePatterns?.length > 0) {
|
|
patterns.failurePatterns.forEach(pattern => {
|
|
recommendations.push({
|
|
type: 'IMPROVEMENT',
|
|
priority: 'HIGH',
|
|
message: `Consider avoiding ${pattern.decisionType} decisions - only ${pattern.successRate.toFixed(1)}% success rate`,
|
|
actionable: true
|
|
});
|
|
});
|
|
}
|
|
|
|
if (patterns?.successfulPatterns?.length > 0) {
|
|
const bestPattern = patterns.successfulPatterns.reduce((best, current) =>
|
|
current.successRate > best.successRate ? current : best
|
|
);
|
|
|
|
recommendations.push({
|
|
type: 'OPTIMIZATION',
|
|
priority: 'MEDIUM',
|
|
message: `${bestPattern.decisionType} decisions show ${bestPattern.successRate.toFixed(1)}% success rate - consider using more often`,
|
|
actionable: true
|
|
});
|
|
}
|
|
|
|
return recommendations;
|
|
}
|
|
}
|
|
|
|
// Export for use in other modules
|
|
module.exports = StopLossDecisionLearner;
|
|
|
|
// Direct execution for testing
|
|
if (require.main === module) {
|
|
const learner = new StopLossDecisionLearner();
|
|
|
|
console.log('🧠 Stop Loss Decision Learning System');
|
|
console.log('📊 Ready to make your AI smarter with every decision!');
|
|
|
|
// Demo decision recording
|
|
setTimeout(async () => {
|
|
await learner.recordDecision({
|
|
tradeId: 'demo_001',
|
|
symbol: 'SOL-PERP',
|
|
decision: 'TIGHTEN_SL',
|
|
distanceFromSL: 2.3,
|
|
reasoning: 'Market showing weakness, reducing risk exposure',
|
|
currentPrice: 182.45,
|
|
confidenceScore: 0.8,
|
|
expectedOutcome: 'BETTER_RESULT'
|
|
});
|
|
|
|
const report = await learner.generateLearningReport();
|
|
console.log('\n📊 LEARNING REPORT:', JSON.stringify(report, null, 2));
|
|
}, 1000);
|
|
}
|