511 lines
18 KiB
Python
511 lines
18 KiB
Python
import json
|
|
import random
|
|
import math
|
|
from datetime import datetime, date
|
|
from typing import Dict, Optional, Tuple
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from database import User, UserRPU, InventoryItem
|
|
from promo_phase import get_promo_effect_for_user, get_current_phase
|
|
|
|
BASE_RARITY_WEIGHTS = {
|
|
"Consumer Grade": 10000, "Industrial Grade": 5000,
|
|
"Mil-Spec": 2500, "Mil-Spec Grade": 2500,
|
|
"Restricted": 625, "Classified": 125,
|
|
"Covert": 25, "Rare Special Item": 5, "Extraordinary": 5
|
|
}
|
|
|
|
RARITY_TO_FIELD = {
|
|
"Consumer Grade": "consumer_multiplier",
|
|
"Industrial Grade": "industrial_multiplier",
|
|
"Mil-Spec": "mil_spec_multiplier",
|
|
"Mil-Spec Grade": "mil_spec_multiplier",
|
|
"Restricted": "restricted_multiplier",
|
|
"Classified": "classified_multiplier",
|
|
"Covert": "covert_multiplier",
|
|
"Rare Special Item": "rare_special_multiplier",
|
|
"Extraordinary": "rare_special_multiplier"
|
|
}
|
|
|
|
RARITY_RANK = {
|
|
"Consumer Grade": 0, "Industrial Grade": 1,
|
|
"Mil-Spec": 2, "Mil-Spec Grade": 2,
|
|
"Restricted": 3, "Classified": 4, "Covert": 5,
|
|
"Rare Special Item": 6, "Extraordinary": 6
|
|
}
|
|
|
|
CEILING_BASE_MULTIPLIER = 3.0
|
|
CEILING_MIN = 1000.0
|
|
CEILING_SESSION_HISTORY = {0: 5.0, 1: 4.0, 2: 3.5, -1: 3.0}
|
|
|
|
BUDGET_MAX = 100.0
|
|
BUDGET_CONSUMPTION_RATE = 0.001
|
|
BUDGET_REGEN_PER_OPEN = 0.5
|
|
BUDGET_LOW_THRESHOLD = 20.0
|
|
|
|
COMEBACK_TRIGGER_COUNT = 5
|
|
COMEBACK_TRIGGER_VALUE_RATIO = 0.3
|
|
COMEBACK_OPENINGS = 5
|
|
COMEBACK_MULTIPLIER_MAX = 3.0
|
|
|
|
HOT_THRESHOLD_SPENT = 10000
|
|
HOT_THRESHOLD_WITHDRAWAL = 0.1
|
|
COLD_DAYS_INACTIVE = 7
|
|
COLD_SPENT_PER_DAY = 100
|
|
|
|
_BATCH_MODE = False
|
|
_BATCH_WEIGHTS_CACHE = {}
|
|
|
|
|
|
def _rpu_commit_or_flush(db):
|
|
if _BATCH_MODE:
|
|
db.flush()
|
|
else:
|
|
db.commit()
|
|
|
|
|
|
async def _rpu_commit_or_flush_async(db: AsyncSession):
|
|
if _BATCH_MODE:
|
|
await db.flush()
|
|
else:
|
|
await db.commit()
|
|
|
|
|
|
def _batch_cache_get(user_id: int):
|
|
if _BATCH_MODE and user_id in _BATCH_WEIGHTS_CACHE:
|
|
return _BATCH_WEIGHTS_CACHE[user_id]
|
|
return None
|
|
|
|
|
|
def _batch_cache_set(user_id: int, weights: dict):
|
|
if _BATCH_MODE:
|
|
_BATCH_WEIGHTS_CACHE[user_id] = weights
|
|
|
|
|
|
def _batch_cache_clear(user_id: int = None):
|
|
if user_id is not None:
|
|
_BATCH_WEIGHTS_CACHE.pop(user_id, None)
|
|
else:
|
|
_BATCH_WEIGHTS_CACHE.clear()
|
|
|
|
|
|
async def get_user_rpu(db: AsyncSession, user_id: int) -> UserRPU:
|
|
result = await db.execute(select(UserRPU).where(UserRPU.user_id == user_id))
|
|
rpu = result.scalar_one_or_none()
|
|
if not rpu:
|
|
rpu = UserRPU(user_id=user_id)
|
|
db.add(rpu)
|
|
await _rpu_commit_or_flush_async(db)
|
|
await db.refresh(rpu)
|
|
return rpu
|
|
|
|
|
|
async def check_and_reset_session(db: AsyncSession, user_id: int):
|
|
rpu = await get_user_rpu(db, user_id)
|
|
today = date.today()
|
|
if rpu.session_reset_date is None:
|
|
rpu.session_reset_date = datetime.utcnow()
|
|
rpu.luck_budget = BUDGET_MAX
|
|
rpu.session_spent = 0; rpu.session_won = 0
|
|
rpu.ceiling_break_session = 0
|
|
rpu.comeback_active = False; rpu.comeback_openings_left = 0
|
|
rpu.comeback_multiplier = 1.0; rpu.consecutive_loss_count = 0
|
|
rpu.consecutive_loss_value = 0.0
|
|
await _rpu_commit_or_flush_async(db)
|
|
return
|
|
last_reset = rpu.session_reset_date.date()
|
|
if last_reset < today:
|
|
rpu.session_reset_date = datetime.utcnow()
|
|
rpu.luck_budget = BUDGET_MAX
|
|
rpu.session_spent = 0; rpu.session_won = 0
|
|
rpu.ceiling_break_session = 0
|
|
rpu.comeback_active = False; rpu.comeback_openings_left = 0
|
|
rpu.comeback_multiplier = 1.0; rpu.consecutive_loss_count = 0
|
|
rpu.consecutive_loss_value = 0.0
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
|
|
async def calculate_ceiling(db: AsyncSession, user_id: int) -> float:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
return CEILING_MIN
|
|
total_dep = max(0.0, user.total_deposited)
|
|
rpu = await get_user_rpu(db, user_id)
|
|
session_count = 0
|
|
if rpu.session_reset_date and user.created_at:
|
|
days_since = (datetime.utcnow() - user.created_at).days
|
|
session_count = max(0, days_since)
|
|
mult = CEILING_SESSION_HISTORY.get(session_count, CEILING_SESSION_HISTORY[-1])
|
|
mult *= rpu.ceiling_multiplier
|
|
ceiling = total_dep * mult
|
|
return max(CEILING_MIN, ceiling)
|
|
|
|
|
|
async def get_total_inventory_value(db: AsyncSession, user_id: int) -> float:
|
|
result = await db.execute(
|
|
select(func.coalesce(func.sum(InventoryItem.price_rub), 0))
|
|
.where(InventoryItem.user_id == user_id)
|
|
)
|
|
return float(result.scalar() or 0)
|
|
|
|
|
|
async def get_user_total_value(db: AsyncSession, user_id: int) -> float:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
balance = user.balance if user else 0
|
|
inv = await get_total_inventory_value(db, user_id)
|
|
return balance + inv
|
|
|
|
|
|
async def get_ceiling_luck_factor(db: AsyncSession, user_id: int) -> float:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
return 1.0
|
|
ceiling = await calculate_ceiling(db, user_id)
|
|
inv_value = await get_total_inventory_value(db, user_id)
|
|
withdrawn = user.total_withdrawn or 0
|
|
extracted_value = inv_value + withdrawn
|
|
if ceiling <= 0:
|
|
return 1.0
|
|
ratio = extracted_value / ceiling
|
|
if ratio < 0.8:
|
|
return 1.0
|
|
if ratio >= 1.0:
|
|
return 0.3
|
|
return 1.0 - (ratio - 0.8) / 0.2 * 0.7
|
|
|
|
|
|
async def check_ceiling_breach(db: AsyncSession, user_id: int, item_price: float, case_price: float) -> bool:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
return False
|
|
ceiling = await calculate_ceiling(db, user_id)
|
|
total_value = user.balance + await get_total_inventory_value(db, user_id)
|
|
if total_value < ceiling:
|
|
return False
|
|
if item_price < ceiling * 0.5:
|
|
return False
|
|
return True
|
|
|
|
|
|
async def apply_ceiling_breach(db: AsyncSession, user_id: int, item_price: float):
|
|
rpu = await get_user_rpu(db, user_id)
|
|
rpu.ceiling_multiplier = min(3.0, rpu.ceiling_multiplier * 1.5)
|
|
rpu.ceiling_break_count += 1
|
|
rpu.ceiling_break_session += 1
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
|
|
async def apply_ceiling_withdrawal_penalty(db: AsyncSession, user_id: int):
|
|
rpu = await get_user_rpu(db, user_id)
|
|
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
|
|
async def consume_luck_budget(db: AsyncSession, user_id: int, item_price: float):
|
|
rpu = await get_user_rpu(db, user_id)
|
|
rpu.luck_budget = max(0.0, rpu.luck_budget - item_price * BUDGET_CONSUMPTION_RATE)
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
|
|
async def regen_luck_budget(db: AsyncSession, user_id: int):
|
|
rpu = await get_user_rpu(db, user_id)
|
|
rpu.luck_budget = min(BUDGET_MAX, rpu.luck_budget + BUDGET_REGEN_PER_OPEN)
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
|
|
def get_budget_factor(rpu: UserRPU) -> float:
|
|
if rpu.luck_budget >= BUDGET_LOW_THRESHOLD:
|
|
return 1.0
|
|
return max(0.3, rpu.luck_budget / BUDGET_LOW_THRESHOLD)
|
|
|
|
|
|
async def update_comeback_tracking(db: AsyncSession, user_id: int, spent: float, received_value: float):
|
|
rpu = await get_user_rpu(db, user_id)
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
return
|
|
if received_value < spent * 0.8:
|
|
rpu.consecutive_loss_count += 1
|
|
rpu.consecutive_loss_value += (spent - received_value)
|
|
else:
|
|
rpu.consecutive_loss_count = 0
|
|
rpu.consecutive_loss_value = 0.0
|
|
total_dep = max(1.0, user.total_deposited)
|
|
loss_ratio = rpu.consecutive_loss_value / total_dep
|
|
if (rpu.consecutive_loss_count >= COMEBACK_TRIGGER_COUNT or
|
|
loss_ratio >= COMEBACK_TRIGGER_VALUE_RATIO):
|
|
if not rpu.comeback_active:
|
|
rpu.comeback_active = True
|
|
rpu.comeback_openings_left = COMEBACK_OPENINGS
|
|
rpu.comeback_multiplier = COMEBACK_MULTIPLIER_MAX
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
|
|
async def apply_comeback(db: AsyncSession, user_id: int) -> float:
|
|
rpu = await get_user_rpu(db, user_id)
|
|
if not rpu.comeback_active or rpu.comeback_openings_left <= 0:
|
|
return 1.0
|
|
mult = rpu.comeback_multiplier
|
|
rpu.comeback_openings_left -= 1
|
|
if rpu.comeback_openings_left <= 0:
|
|
rpu.comeback_active = False
|
|
rpu.comeback_multiplier = 1.0
|
|
rpu.consecutive_loss_count = 0
|
|
rpu.consecutive_loss_value = 0.0
|
|
await _rpu_commit_or_flush_async(db)
|
|
return mult
|
|
|
|
|
|
async def analyze_hot_cold(db: AsyncSession, user_id: int) -> Tuple[bool, bool, float]:
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
return False, True, 0.0
|
|
total_dep = max(0.0, user.total_deposited)
|
|
total_wd = max(0.0, user.total_withdrawn)
|
|
wd_ratio = total_wd / max(1.0, total_dep)
|
|
is_hot = (total_dep >= HOT_THRESHOLD_SPENT and wd_ratio < HOT_THRESHOLD_WITHDRAWAL)
|
|
rpu = await get_user_rpu(db, user_id)
|
|
is_cold = False
|
|
if rpu.last_activity_date:
|
|
days_since = (datetime.utcnow() - rpu.last_activity_date).days
|
|
if days_since >= COLD_DAYS_INACTIVE:
|
|
is_cold = True
|
|
else:
|
|
is_cold = True
|
|
days_active = max(1, (datetime.utcnow() - user.created_at).days)
|
|
spent_per_day = total_dep / days_active
|
|
if spent_per_day < COLD_SPENT_PER_DAY and total_dep > 0:
|
|
is_cold = True
|
|
hot_score = total_dep - (total_wd * 2)
|
|
rpu.hot_score = hot_score
|
|
rpu.last_activity_date = datetime.utcnow()
|
|
await _rpu_commit_or_flush_async(db)
|
|
return is_hot, is_cold, wd_ratio
|
|
|
|
|
|
async def check_withdrawal_penalty(db: AsyncSession, user_id: int):
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
return
|
|
total_dep = max(1.0, user.total_deposited)
|
|
total_wd = max(0.0, user.total_withdrawn)
|
|
wd_ratio = total_wd / total_dep
|
|
if wd_ratio > 0.5:
|
|
rpu = await get_user_rpu(db, user_id)
|
|
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - wd_ratio * 0.3)
|
|
rpu.ceiling_multiplier = max(0.5, rpu.ceiling_multiplier * 0.7)
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
|
|
def calculate_rpu_level(rpu: UserRPU) -> float:
|
|
multipliers = [
|
|
rpu.consumer_multiplier, rpu.industrial_multiplier,
|
|
rpu.mil_spec_multiplier, rpu.restricted_multiplier,
|
|
rpu.classified_multiplier, rpu.covert_multiplier,
|
|
rpu.rare_special_multiplier
|
|
]
|
|
avg = sum(multipliers) / len(multipliers)
|
|
if avg >= 1.0:
|
|
level = 50 - ((avg - 1.0) / 2.0) * 100
|
|
else:
|
|
level = 50 + ((1.0 - avg) / 0.9) * 50
|
|
return max(0, min(100, level))
|
|
|
|
|
|
def get_rpu_description(level: float) -> str:
|
|
if level < 20: return "🍀 Очень везучий"
|
|
elif level < 35: return "😊 Везучий"
|
|
elif level < 45: return "🙂 Немного везучий"
|
|
elif level < 55: return "😐 Обычный"
|
|
elif level < 70: return "🙁 Немного невезучий"
|
|
elif level < 85: return "😟 Невезучий"
|
|
else: return "💀 Проклятый"
|
|
|
|
|
|
async def get_adjusted_weights(db: AsyncSession, user_id: int) -> Dict[str, float]:
|
|
cached = _batch_cache_get(user_id)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
await check_and_reset_session(db, user_id)
|
|
rpu = await get_user_rpu(db, user_id)
|
|
level = calculate_rpu_level(rpu)
|
|
is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id)
|
|
|
|
promo = get_promo_effect_for_user(user_id, rpu.hot_score, wd_ratio, is_cold)
|
|
comeback_mult = await apply_comeback(db, user_id)
|
|
budget_factor = get_budget_factor(rpu)
|
|
ceiling_factor = await get_ceiling_luck_factor(db, user_id)
|
|
|
|
adjusted = {}
|
|
for rarity, base_weight in BASE_RARITY_WEIGHTS.items():
|
|
field = RARITY_TO_FIELD.get(rarity)
|
|
base_multiplier = getattr(rpu, field, 1.0) if field else 1.0
|
|
rarity_index = RARITY_RANK.get(rarity, 0)
|
|
|
|
if level < 50:
|
|
luck_factor = 1.0 + ((50 - level) / 50) * rarity_index * 0.3
|
|
else:
|
|
luck_factor = 1.0 - ((level - 50) / 50) * rarity_index * 0.2
|
|
luck_factor = max(0.1, min(3.0, luck_factor))
|
|
|
|
final = base_multiplier * rpu.luck_multiplier * luck_factor
|
|
final *= (1.0 + promo["luck_modifier"])
|
|
final *= budget_factor
|
|
final *= comeback_mult
|
|
final *= ceiling_factor
|
|
|
|
if rarity_index >= 3:
|
|
final *= (1.0 + promo.get("rare_chance_modifier", 0))
|
|
|
|
final = max(0.05, min(5.0, final))
|
|
adjusted[rarity] = base_weight * final
|
|
|
|
_batch_cache_set(user_id, adjusted)
|
|
return adjusted
|
|
|
|
|
|
async def auto_adjust_rpu(db: AsyncSession, user_id: int):
|
|
rpu = await get_user_rpu(db, user_id)
|
|
if not rpu.auto_adjust:
|
|
return
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
return
|
|
|
|
if rpu.total_opened > 0 and rpu.total_spent > 0:
|
|
roi = rpu.total_value_received / rpu.total_spent
|
|
if roi < 0.4:
|
|
boost = (0.4 - roi) * 2
|
|
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
|
|
for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
|
setattr(rpu, r, min(2.0, getattr(rpu, r) + boost * 0.3))
|
|
elif roi > 2.0:
|
|
penalty = (roi - 2.0) * 0.5
|
|
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty)
|
|
for r in ['restricted_multiplier', 'classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
|
setattr(rpu, r, max(0.3, getattr(rpu, r) - penalty * 0.5))
|
|
|
|
streak = rpu.current_streak
|
|
if streak <= -5:
|
|
boost = min(0.3, abs(streak) * 0.03)
|
|
rpu.luck_multiplier = min(2.5, rpu.luck_multiplier + boost)
|
|
rpu.classified_multiplier = min(2.0, rpu.classified_multiplier + boost * 0.5)
|
|
rpu.covert_multiplier = min(1.8, rpu.covert_multiplier + boost * 0.4)
|
|
rpu.rare_special_multiplier = min(1.5, rpu.rare_special_multiplier + boost * 0.3)
|
|
if streak >= 5:
|
|
penalty = min(0.2, streak * 0.02)
|
|
rpu.luck_multiplier = max(0.3, rpu.luck_multiplier - penalty)
|
|
for r in ['classified_multiplier', 'covert_multiplier', 'rare_special_multiplier']:
|
|
setattr(rpu, r, max(0.2, getattr(rpu, r) - penalty * 0.5))
|
|
if rpu.total_opened > 50:
|
|
rpu.classified_multiplier = min(1.5, rpu.classified_multiplier + 0.05)
|
|
rpu.covert_multiplier = min(1.3, rpu.covert_multiplier + 0.03)
|
|
|
|
for field in ['consumer_multiplier', 'industrial_multiplier', 'mil_spec_multiplier',
|
|
'restricted_multiplier', 'classified_multiplier', 'covert_multiplier',
|
|
'rare_special_multiplier']:
|
|
setattr(rpu, field, max(0.1, min(3.0, getattr(rpu, field))))
|
|
rpu.luck_multiplier = max(0.1, min(3.0, rpu.luck_multiplier))
|
|
rpu.last_adjustment = datetime.utcnow()
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
|
|
async def update_rpu_stats(db: AsyncSession, user_id: int, spent: float, opened: int = 1,
|
|
received_value: float = 0.0, rarity: str = None, is_rare: bool = False):
|
|
rpu = await get_user_rpu(db, user_id)
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
|
|
rpu.total_spent += spent
|
|
rpu.total_opened += opened
|
|
rpu.total_value_received += received_value
|
|
rpu.session_spent += spent
|
|
rpu.session_won += received_value
|
|
|
|
if is_rare or received_value > spent * 3:
|
|
rpu.current_streak = min(20, rpu.current_streak + 1)
|
|
else:
|
|
rpu.current_streak = max(-20, rpu.current_streak - 1)
|
|
|
|
if rpu.current_streak > rpu.best_streak:
|
|
rpu.best_streak = rpu.current_streak
|
|
if rpu.current_streak < rpu.worst_streak:
|
|
rpu.worst_streak = rpu.current_streak
|
|
|
|
await consume_luck_budget(db, user_id, received_value)
|
|
await regen_luck_budget(db, user_id)
|
|
await update_comeback_tracking(db, user_id, spent, received_value)
|
|
|
|
if user and is_rare and received_value > 0:
|
|
if await check_ceiling_breach(db, user_id, received_value, spent):
|
|
await apply_ceiling_breach(db, user_id, received_value)
|
|
|
|
await analyze_hot_cold(db, user_id)
|
|
await _rpu_commit_or_flush_async(db)
|
|
|
|
if rpu.auto_adjust:
|
|
await auto_adjust_rpu(db, user_id)
|
|
|
|
|
|
async def get_rpu_v2_info(db: AsyncSession, user_id: int) -> dict:
|
|
await check_and_reset_session(db, user_id)
|
|
rpu = await get_user_rpu(db, user_id)
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
return {}
|
|
|
|
ceiling = await calculate_ceiling(db, user_id)
|
|
inv_value = await get_total_inventory_value(db, user_id)
|
|
total_value = user.balance + inv_value
|
|
is_hot, is_cold, wd_ratio = await analyze_hot_cold(db, user_id)
|
|
|
|
session_count = max(0, (datetime.utcnow() - user.created_at).days) if user.created_at else 0
|
|
|
|
return {
|
|
"rpu_level": round(calculate_rpu_level(rpu)),
|
|
"description": get_rpu_description(calculate_rpu_level(rpu)),
|
|
"luck_multiplier": rpu.luck_multiplier,
|
|
"luck_budget": round(rpu.luck_budget, 1),
|
|
"ceiling": round(ceiling, 2),
|
|
"ceiling_multiplier": round(rpu.ceiling_multiplier, 2),
|
|
"ceiling_breach_total": rpu.ceiling_break_count,
|
|
"ceiling_breach_session": rpu.ceiling_break_session,
|
|
"total_value": round(total_value, 2),
|
|
"balance": round(user.balance, 2),
|
|
"inventory_value": round(inv_value, 2),
|
|
"total_deposited": round(user.total_deposited, 2),
|
|
"total_withdrawn": round(user.total_withdrawn, 2),
|
|
"session": {
|
|
"number": session_count,
|
|
"spent": round(rpu.session_spent, 2),
|
|
"won": round(rpu.session_won, 2),
|
|
},
|
|
"comeback": {
|
|
"active": rpu.comeback_active,
|
|
"openings_left": rpu.comeback_openings_left,
|
|
"multiplier": round(rpu.comeback_multiplier, 2),
|
|
},
|
|
"streak": {
|
|
"current": rpu.current_streak,
|
|
"best": rpu.best_streak,
|
|
"worst": rpu.worst_streak,
|
|
},
|
|
"hot_cold": {
|
|
"is_hot": is_hot,
|
|
"is_cold": is_cold,
|
|
"hot_score": round(rpu.hot_score, 2),
|
|
"withdrawal_ratio": round(wd_ratio, 3),
|
|
},
|
|
"promo_phase": get_current_phase(),
|
|
}
|