New limits CAP

parent 2d93850e
......@@ -189,14 +189,16 @@ Store comprehensive extraction metrics in `extraction_stats` table:
- CAP applied status
- Result breakdown
#### Step 6.3: Shortfall Tracking
#### Step 6.3: Redistribution Adjustment Tracking
**Expected Redistribution = Total Payin × CAP Percentage**
**Actual Redistribution = Sum of all win_amounts**
**Shortfall = max(0, Expected - Actual)**
**Adjustment = Expected - Actual**
- Positive: Under-redistribution (shortfall) - accumulated to increase future CAP
- Negative: Over-redistribution (surplus) - accumulated to decrease future CAP
Update daily shortfall tracking for future CAP adjustments.
Update daily redistribution adjustment tracking for future CAP adjustments.
### Phase 7: System Notifications
......@@ -216,7 +218,7 @@ Send `MATCH_DONE` message to advance to next match.
- **Purpose**: Prevent excessive payouts that could harm profitability
- **Mechanism**: Only select results where total payout ≤ CAP threshold
- **Adjustment Factors**:
- Accumulated shortfalls from previous extractions
- Accumulated redistribution adjustments from previous extractions
- Committed UNDER/OVER payouts
- Total intake from all betting activity
......@@ -228,7 +230,7 @@ Send `MATCH_DONE` message to advance to next match.
### Profit Maximization
- **Weighted Selection**: Prefers results maximizing redistribution
- **Fallback Protection**: Always selects result, even if CAP exceeded
- **Shortfall Carryover**: Tracks and compensates for under-redistribution
- **Adjustment Carryover**: Tracks and compensates for redistribution imbalances (both under and over)
### Data Integrity
- **Transaction Safety**: All updates in database transactions
......@@ -238,7 +240,7 @@ Send `MATCH_DONE` message to advance to next match.
## Configuration Parameters
- **CAP Percentage**: Default 70%, configurable via `extraction_redistribution_cap`
- **Shortfall Tracking**: Daily accumulated shortfalls affect future CAP calculations
- **Adjustment Tracking**: Daily accumulated redistribution adjustments affect future CAP calculations
- **Result Associations**: Configurable via `extraction_associations` table
- **Betting Mode**: Affects match status progression but not extraction logic
......
......@@ -12,7 +12,7 @@ from typing import Optional, Dict, Any, List
from .thread_manager import ThreadedComponent
from .message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..database.manager import DatabaseManager
from ..database.models import MatchModel, MatchStatus, BetDetailModel, MatchOutcomeModel, GameConfigModel, ExtractionAssociationModel, DailyRedistributionShortfallModel, MatchTemplateModel, MatchOutcomeTemplateModel
from ..database.models import MatchModel, MatchStatus, BetDetailModel, MatchOutcomeModel, GameConfigModel, ExtractionAssociationModel, PersistentRedistributionAdjustmentModel, MatchTemplateModel, MatchOutcomeTemplateModel
from ..utils.timezone_utils import get_today_venue_date
logger = logging.getLogger(__name__)
......@@ -2046,60 +2046,65 @@ class GamesThread(ThreadedComponent):
logger.error(f"Failed to get redistribution CAP: {e}")
return 70.0
def _get_daily_shortfall(self, date, session) -> float:
"""Get accumulated shortfall for a specific date"""
def _get_daily_redistribution_adjustment(self, date, session) -> float:
"""Get accumulated redistribution adjustment (persistent across application restarts)"""
try:
shortfall_record = session.query(DailyRedistributionShortfallModel).filter_by(
date=date
# Get the most recent accumulated_shortfall value from any record
latest_record = session.query(PersistentRedistributionAdjustmentModel).order_by(
PersistentRedistributionAdjustmentModel.updated_at.desc()
).first()
if shortfall_record:
logger.debug(f"Found shortfall record for {date}: {shortfall_record.accumulated_shortfall}")
return shortfall_record.accumulated_shortfall
if latest_record:
logger.debug(f"Found persistent redistribution adjustment: {latest_record.accumulated_shortfall}")
return latest_record.accumulated_shortfall
else:
logger.debug(f"No shortfall record found for {date}, returning 0.0")
logger.debug("No redistribution adjustment records found, returning 0.0")
return 0.0
except Exception as e:
logger.error(f"Failed to get daily shortfall for {date}: {e}")
logger.error(f"Failed to get persistent redistribution adjustment: {e}")
return 0.0
def _update_daily_shortfall(self, date, payin_amount, redistributed_amount, cap_percentage, session):
"""Update daily shortfall tracking after extraction"""
def _update_daily_redistribution_adjustment(self, date, payin_amount, redistributed_amount, cap_percentage, session):
"""Update persistent redistribution adjustment tracking after extraction"""
try:
# Calculate the shortfall for this extraction
# Calculate the redistribution adjustment for this extraction
# Positive: under-redistribution (shortfall), Negative: over-redistribution (surplus)
expected_redistribution = payin_amount * (cap_percentage / 100.0)
shortfall = max(0, expected_redistribution - redistributed_amount)
adjustment = expected_redistribution - redistributed_amount
logger.info(f"💰 [SHORTFALL DEBUG] Payin: {payin_amount:.2f}, Expected: {expected_redistribution:.2f}, Redistributed: {redistributed_amount:.2f}, Shortfall: {shortfall:.2f}")
logger.info(f"💰 [ADJUSTMENT DEBUG] Payin: {payin_amount:.2f}, Expected: {expected_redistribution:.2f}, Redistributed: {redistributed_amount:.2f}, Adjustment: {adjustment:.2f}")
# Get or create the daily record
shortfall_record = session.query(DailyRedistributionShortfallModel).filter_by(
date=date
# Use a fixed date for the global persistent record
global_date = datetime.date(1970, 1, 1) # Fixed date for global record
# Get or create the global record
adjustment_record = session.query(PersistentRedistributionAdjustmentModel).filter_by(
date=global_date
).first()
if not shortfall_record:
shortfall_record = DailyRedistributionShortfallModel(
date=date,
accumulated_shortfall=shortfall,
if not adjustment_record:
adjustment_record = PersistentRedistributionAdjustmentModel(
date=global_date,
accumulated_shortfall=adjustment,
total_payin=payin_amount,
total_redistributed=redistributed_amount,
cap_percentage=cap_percentage
)
session.add(shortfall_record)
logger.info(f"Created new shortfall record for {date} with shortfall {shortfall:.2f}")
session.add(adjustment_record)
logger.info(f"Created global redistribution adjustment record with adjustment {adjustment:.2f}")
else:
# Update existing record
shortfall_record.accumulated_shortfall += shortfall
shortfall_record.total_payin += payin_amount
shortfall_record.total_redistributed += redistributed_amount
shortfall_record.cap_percentage = cap_percentage # Update to latest
logger.info(f"Updated shortfall record for {date}, new accumulated shortfall: {shortfall_record.accumulated_shortfall:.2f}")
# Update existing global record
adjustment_record.accumulated_shortfall += adjustment
adjustment_record.total_payin += payin_amount
adjustment_record.total_redistributed += redistributed_amount
adjustment_record.cap_percentage = cap_percentage # Update to latest
logger.info(f"Updated global redistribution adjustment record, new accumulated adjustment: {adjustment_record.accumulated_shortfall:.2f}")
session.commit()
except Exception as e:
logger.error(f"Failed to update daily shortfall for {date}: {e}")
logger.error(f"Failed to update persistent redistribution adjustment: {e}")
session.rollback()
def _weighted_random_selection(self, under_coeff: float, over_coeff: float) -> str:
......@@ -2580,10 +2585,10 @@ class GamesThread(ThreadedComponent):
# Get redistribution CAP
cap_percentage = self._get_redistribution_cap()
# Get accumulated shortfall from previous extractions for today
# Get accumulated redistribution adjustment from previous extractions for today
today = self._get_today_venue_date()
accumulated_shortfall = self._get_daily_shortfall(today, session)
logger.info(f"🎯 [EXTRACTION DEBUG] Accumulated shortfall: {accumulated_shortfall:.2f}")
accumulated_shortfall = self._get_daily_redistribution_adjustment(today, session)
logger.info(f"🎯 [EXTRACTION DEBUG] Accumulated redistribution adjustment: {accumulated_shortfall:.2f}")
# Calculate base CAP threshold using ALL bets (UNDER/OVER + other bets)
total_payin_all_bets = total_payin + total_bet_amount
......@@ -2660,10 +2665,10 @@ class GamesThread(ThreadedComponent):
logger.info(f"📈 [EXTRACTION DEBUG] Step 9: Collecting match statistics")
self._collect_match_statistics(match_id, fixture_id, selected_result, session)
# Step 10: Update daily shortfall tracking
logger.info(f"💰 [EXTRACTION DEBUG] Step 10: Updating daily shortfall tracking")
# Step 10: Update daily redistribution adjustment tracking
logger.info(f"💰 [EXTRACTION DEBUG] Step 10: Updating daily redistribution adjustment tracking")
today = self._get_today_venue_date()
self._update_daily_shortfall(today, total_payin_all_bets, payouts[selected_result], cap_percentage, session)
self._update_daily_redistribution_adjustment(today, total_payin_all_bets, payouts[selected_result], cap_percentage, session)
logger.info(f"✅ [EXTRACTION DEBUG] Result extraction completed successfully: selected {selected_result}")
......
......@@ -2731,6 +2731,165 @@ class Migration_035_AddDailyRedistributionShortfallTable(DatabaseMigration):
return False
class Migration_037_RenameDailyRedistributionShortfallTable(DatabaseMigration):
"""Rename daily_redistribution_shortfall table to persistent_redistribution_adjustment"""
def __init__(self):
super().__init__("037", "Rename daily_redistribution_shortfall table to persistent_redistribution_adjustment")
def up(self, db_manager) -> bool:
"""Rename the table"""
try:
with db_manager.engine.connect() as conn:
# Check if the old table exists
result = conn.execute(text("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='daily_redistribution_shortfall'
"""))
old_table_exists = result.fetchone() is not None
if old_table_exists:
# Rename the table
conn.execute(text("""
ALTER TABLE daily_redistribution_shortfall
RENAME TO persistent_redistribution_adjustment
"""))
# Note: SQLite doesn't support ALTER INDEX RENAME, so we'll leave the old index name
# The index will be recreated with the new name when the table is recreated in future migrations
conn.commit()
logger.info("Renamed daily_redistribution_shortfall table to persistent_redistribution_adjustment")
else:
logger.info("daily_redistribution_shortfall table does not exist, nothing to rename")
return True
except Exception as e:
logger.error(f"Failed to rename daily_redistribution_shortfall table: {e}")
return False
def down(self, db_manager) -> bool:
"""Rename back to the old table name"""
try:
with db_manager.engine.connect() as conn:
# Check if the new table exists
result = conn.execute(text("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='persistent_redistribution_adjustment'
"""))
new_table_exists = result.fetchone() is not None
if new_table_exists:
# Rename back to the old name
conn.execute(text("""
ALTER TABLE persistent_redistribution_adjustment
RENAME TO daily_redistribution_shortfall
"""))
# Note: SQLite doesn't support ALTER INDEX RENAME, so we'll leave the index name as-is
conn.commit()
logger.info("Renamed persistent_redistribution_adjustment table back to daily_redistribution_shortfall")
else:
logger.info("persistent_redistribution_adjustment table does not exist, nothing to rename back")
return True
except Exception as e:
logger.error(f"Failed to rename back to daily_redistribution_shortfall table: {e}")
return False
class Migration_038_AddWin1Win2Associations(DatabaseMigration):
"""Add WIN1 and WIN2 result options and extraction associations for X1, X2, and 12 outcomes"""
def __init__(self):
super().__init__("038", "Add WIN1 and WIN2 result options and extraction associations for X1, X2, and 12 outcomes")
def up(self, db_manager) -> bool:
"""Add WIN1 and WIN2 result options and associations if they don't exist"""
try:
with db_manager.engine.connect() as conn:
# Add WIN1 and WIN2 to result_options table
result_options = [
('WIN1', 'Fighter 1 wins result', 9),
('WIN2', 'Fighter 2 wins result', 10)
]
for result_name, description, sort_order in result_options:
conn.execute(text("""
INSERT OR IGNORE INTO result_options
(result_name, description, is_active, sort_order, created_at, updated_at)
VALUES (:result_name, :description, 1, :sort_order, datetime('now'), datetime('now'))
"""), {
'result_name': result_name,
'description': description,
'sort_order': sort_order
})
# Add WIN1 associations: X1 and 12 -> WIN1
associations = [
('X1', 'WIN1'),
('12', 'WIN1'),
('X2', 'WIN2'),
('12', 'WIN2')
]
for outcome_name, extraction_result in associations:
conn.execute(text("""
INSERT OR IGNORE INTO extraction_associations
(outcome_name, extraction_result, is_default, created_at, updated_at)
VALUES (:outcome_name, :extraction_result, 1, datetime('now'), datetime('now'))
"""), {
'outcome_name': outcome_name,
'extraction_result': extraction_result
})
conn.commit()
logger.info("Added WIN1 and WIN2 result options and extraction associations")
return True
except Exception as e:
logger.error(f"Failed to add WIN1 and WIN2 result options and associations: {e}")
return False
def down(self, db_manager) -> bool:
"""Remove the WIN1 and WIN2 result options and associations added by this migration"""
try:
with db_manager.engine.connect() as conn:
# Remove WIN1 and WIN2 from result_options
conn.execute(text("""
DELETE FROM result_options
WHERE result_name IN ('WIN1', 'WIN2')
"""))
# Remove the specific associations added by this migration
associations = [
('X1', 'WIN1'),
('12', 'WIN1'),
('X2', 'WIN2'),
('12', 'WIN2')
]
for outcome_name, extraction_result in associations:
conn.execute(text("""
DELETE FROM extraction_associations
WHERE outcome_name = :outcome_name
AND extraction_result = :extraction_result
AND is_default = 1
"""), {
'outcome_name': outcome_name,
'extraction_result': extraction_result
})
conn.commit()
logger.info("Removed WIN1 and WIN2 result options and extraction associations")
return True
except Exception as e:
logger.error(f"Failed to remove WIN1 and WIN2 result options and associations: {e}")
return False
class Migration_036_AddMatchTemplatesTables(DatabaseMigration):
"""Add matches_templates and match_outcomes_templates tables for storing match templates"""
......@@ -2886,6 +3045,8 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_034_AddDefaultLicenseText(),
Migration_035_AddDailyRedistributionShortfallTable(),
Migration_036_AddMatchTemplatesTables(),
Migration_037_RenameDailyRedistributionShortfallTable(),
Migration_038_AddWin1Win2Associations(),
]
......
......@@ -947,22 +947,22 @@ class ExtractionStatsModel(BaseModel):
return f'<ExtractionStats Match {self.match_id}: {self.total_bets} bets, {self.total_amount_collected:.2f} collected, {self.actual_result}>'
class DailyRedistributionShortfallModel(BaseModel):
"""Daily redistribution shortfall tracking for CAP adjustments"""
__tablename__ = 'daily_redistribution_shortfall'
class PersistentRedistributionAdjustmentModel(BaseModel):
"""Persistent redistribution adjustment tracking across application restarts"""
__tablename__ = 'persistent_redistribution_adjustments'
__table_args__ = (
Index('ix_daily_redistribution_shortfall_date', 'date'),
UniqueConstraint('date', name='uq_daily_redistribution_shortfall_date'),
Index('ix_persistent_redistribution_adjustments_date', 'date'),
UniqueConstraint('date', name='uq_persistent_redistribution_adjustments_date'),
)
date = Column(Date, nullable=False, unique=True, comment='Date for shortfall tracking')
accumulated_shortfall = Column(Float(precision=2), default=0.0, nullable=False, comment='Accumulated shortfall from previous extractions')
date = Column(Date, nullable=False, unique=True, comment='Date for adjustment tracking')
accumulated_shortfall = Column(Float(precision=2), default=0.0, nullable=False, comment='Accumulated adjustment from previous extractions')
total_payin = Column(Float(precision=2), default=0.0, nullable=False, comment='Total payin for the day')
total_redistributed = Column(Float(precision=2), default=0.0, nullable=False, comment='Total redistributed for the day')
cap_percentage = Column(Float(precision=2), default=70.0, nullable=False, comment='CAP percentage used for calculations')
def __repr__(self):
return f'<DailyRedistributionShortfall {self.date}: shortfall={self.accumulated_shortfall:.2f}, payin={self.total_payin:.2f}, redistributed={self.total_redistributed:.2f}>'
return f'<PersistentRedistributionAdjustment {self.date}: adjustment={self.accumulated_shortfall:.2f}, payin={self.total_payin:.2f}, redistributed={self.total_redistributed:.2f}>'
class MatchTemplateModel(BaseModel):
......
......@@ -4108,6 +4108,100 @@ def save_redistribution_cap():
return jsonify({"error": str(e)}), 500
# Redistribution Balance API routes (admin-only)
@api_bp.route('/redistribution-balance')
@get_api_auth_decorator()
@get_api_auth_decorator(require_admin=True)
def get_redistribution_balance():
"""Get current redistribution balance (admin only)"""
try:
from ..database.models import PersistentRedistributionAdjustmentModel
from datetime import date
session = api_bp.db_manager.get_session()
try:
# Get the latest redistribution adjustment record
latest_record = session.query(PersistentRedistributionAdjustmentModel)\
.order_by(PersistentRedistributionAdjustmentModel.date.desc())\
.first()
current_balance = 0.0
if latest_record:
current_balance = float(latest_record.accumulated_shortfall)
return jsonify({
"success": True,
"redistribution_balance": current_balance,
"last_updated": latest_record.date.isoformat() if latest_record else None
})
finally:
session.close()
except Exception as e:
logger.error(f"API get redistribution balance error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/redistribution-balance/reset', methods=['POST'])
@get_api_auth_decorator()
@get_api_auth_decorator(require_admin=True)
def reset_redistribution_balance():
"""Reset redistribution balance to zero (admin only)"""
try:
from ..database.models import PersistentRedistributionAdjustmentModel
from datetime import date, datetime
session = api_bp.db_manager.get_session()
try:
# Get the latest redistribution adjustment record
latest_record = session.query(PersistentRedistributionAdjustmentModel)\
.order_by(PersistentRedistributionAdjustmentModel.date.desc())\
.first()
if latest_record:
# Reset the accumulated shortfall to zero
old_balance = float(latest_record.accumulated_shortfall)
latest_record.accumulated_shortfall = 0.0
latest_record.updated_at = datetime.utcnow()
session.commit()
logger.info(f"Redistribution balance reset from {old_balance} to 0.0")
return jsonify({
"success": True,
"message": f"Redistribution balance reset from {old_balance:.2f} to 0.00",
"old_balance": old_balance,
"new_balance": 0.0
})
else:
# No record exists, create one with zero balance
today = date.today()
new_record = PersistentRedistributionAdjustmentModel(
date=today,
accumulated_shortfall=0.0,
cap_percentage=70.0 # Default cap
)
session.add(new_record)
session.commit()
logger.info("Created new redistribution adjustment record with zero balance")
return jsonify({
"success": True,
"message": "Redistribution balance set to 0.00 (new record created)",
"old_balance": None,
"new_balance": 0.0
})
finally:
session.close()
except Exception as e:
logger.error(f"API reset redistribution balance error: {e}")
return jsonify({"error": str(e)}), 500
# Currency Settings API routes
@api_bp.route('/currency-settings')
@get_api_auth_decorator()
......
......@@ -122,6 +122,42 @@
</div>
</div>
{% endif %}
<!-- Redistribution Balance Management (Admin Only) -->
{% if current_user.is_admin %}
<div class="row mt-3 pt-3 border-top">
<div class="col-12">
<h6 class="text-muted mb-3">
<i class="fas fa-balance-scale me-2"></i>Redistribution Balance
</h6>
</div>
<div class="col-md-6 mb-3">
<div class="card bg-light">
<div class="card-body text-center p-3">
<h5 class="card-title mb-2">
<i class="fas fa-coins me-2"></i>Current Balance
</h5>
<div class="h4 mb-2 text-primary" id="redistribution-balance">--</div>
<div class="mt-2">
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>Balance to compensate over/under redistribution
</small>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="d-grid">
<button class="btn btn-outline-danger w-100" id="btn-reset-redistribution-balance">
<i class="fas fa-undo me-2"></i>Reset Balance to Zero
</button>
<small class="text-muted mt-2 d-block">
<i class="fas fa-exclamation-triangle me-1"></i>This will reset the redistribution compensation balance
</small>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
......@@ -460,6 +496,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Load license text configuration
loadLicenseText();
// Load redistribution balance (admin only)
if (document.getElementById('redistribution-balance')) {
loadRedistributionBalance();
}
// Quick action buttons
document.getElementById('btn-play-video').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('playVideoModal')).show();
......@@ -500,6 +541,14 @@ document.addEventListener('DOMContentLoaded', function() {
resetLicenseText();
});
// Redistribution balance reset button (only exists for admin)
const resetBalanceBtn = document.getElementById('btn-reset-redistribution-balance');
if (resetBalanceBtn) {
resetBalanceBtn.addEventListener('click', function() {
resetRedistributionBalance();
});
}
// Admin shutdown button (only exists if user is admin)
const shutdownBtn = document.getElementById('btn-shutdown-app');
if (shutdownBtn) {
......@@ -1012,5 +1061,70 @@ function resetLicenseText() {
document.getElementById('license-text').value = defaultText;
}
function loadRedistributionBalance() {
fetch('/api/redistribution-balance')
.then(response => response.json())
.then(data => {
if (data.success) {
const balanceElement = document.getElementById('redistribution-balance');
const balance = data.redistribution_balance;
balanceElement.textContent = balance.toFixed(2);
// Color coding: green for positive, red for negative, neutral for zero
if (balance > 0) {
balanceElement.className = 'h4 mb-2 text-success';
} else if (balance < 0) {
balanceElement.className = 'h4 mb-2 text-danger';
} else {
balanceElement.className = 'h4 mb-2 text-primary';
}
} else {
document.getElementById('redistribution-balance').textContent = 'Error';
console.error('Failed to load redistribution balance:', data.error);
}
})
.catch(error => {
document.getElementById('redistribution-balance').textContent = 'Error';
console.error('Error loading redistribution balance:', error);
});
}
function resetRedistributionBalance() {
if (!confirm('Are you sure you want to reset the redistribution balance to zero? This action cannot be undone.')) {
return;
}
const resetBtn = document.getElementById('btn-reset-redistribution-balance');
const originalText = resetBtn.innerHTML;
// Show loading state
resetBtn.disabled = true;
resetBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Resetting...';
fetch('/api/redistribution-balance/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
// Reload the balance
loadRedistributionBalance();
} else {
alert('Failed to reset redistribution balance: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error resetting redistribution balance: ' + error.message);
})
.finally(() => {
// Restore button state
resetBtn.disabled = false;
resetBtn.innerHTML = originalText;
});
}
</script>
{% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment