Compare commits
1 Commits
main
...
feat/settl
| Author | SHA1 | Date | |
|---|---|---|---|
| 6264b8806c |
@@ -330,6 +330,24 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'settlement.est_payout': 'Anggaran bayaran',
|
'settlement.est_payout': 'Anggaran bayaran',
|
||||||
'settlement.refund_amount': 'Jumlah bayaran balik',
|
'settlement.refund_amount': 'Jumlah bayaran balik',
|
||||||
'settlement.confirm_btn': 'Sahkan penyelesaian',
|
'settlement.confirm_btn': 'Sahkan penyelesaian',
|
||||||
|
'settlement.smart.btn': 'Skor pintar',
|
||||||
|
'settlement.smart.title': 'Cadangan skor pintar',
|
||||||
|
'settlement.smart.hint': 'Berdasarkan pertaruhan tunggal menunggu; parlay tidak disertakan. Klik kad untuk guna skor.',
|
||||||
|
'settlement.smart.target_hold': 'Sasaran pegangan',
|
||||||
|
'settlement.smart.recalc': 'Kira semula',
|
||||||
|
'settlement.smart.apply': 'Guna skor ini',
|
||||||
|
'settlement.smart.applied': 'Skor telah diisi',
|
||||||
|
'settlement.smart.no_bets': 'Tiada pertaruhan tunggal menunggu',
|
||||||
|
'settlement.smart.empty': 'Tiada cadangan',
|
||||||
|
'settlement.smart.meta': 'Tunggal {singles}, parlay {parlays} dilangkau, {n} skor dibanding',
|
||||||
|
'settlement.smart.hold': 'Pegangan',
|
||||||
|
'settlement.smart.payout': 'Bayaran',
|
||||||
|
'settlement.smart.win_stake': 'Stake menang %',
|
||||||
|
'settlement.smart.wl': 'Menang/Kalah',
|
||||||
|
'settlement.smart.strategy.MIN_PAYOUT': 'Pegangan maks (bayaran min)',
|
||||||
|
'settlement.smart.strategy.MAX_PAYOUT': 'Bayaran pemain maks',
|
||||||
|
'settlement.smart.strategy.BALANCED': 'Seimbang (~50% stake menang)',
|
||||||
|
'settlement.smart.strategy.TARGET_HOLD': 'Sasaran pegangan',
|
||||||
'msg.score_recorded': 'Skor disimpan',
|
'msg.score_recorded': 'Skor disimpan',
|
||||||
'msg.settlement_confirmed': 'Penyelesaian disahkan',
|
'msg.settlement_confirmed': 'Penyelesaian disahkan',
|
||||||
|
|
||||||
|
|||||||
@@ -330,6 +330,24 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'settlement.est_payout': '预计派彩',
|
'settlement.est_payout': '预计派彩',
|
||||||
'settlement.refund_amount': '退款金额',
|
'settlement.refund_amount': '退款金额',
|
||||||
'settlement.confirm_btn': '确认结算',
|
'settlement.confirm_btn': '确认结算',
|
||||||
|
'settlement.smart.btn': '智能比分',
|
||||||
|
'settlement.smart.title': '智能推荐比分',
|
||||||
|
'settlement.smart.hint': '根据本场待结算单关注单,在合理比分范围内穷举并计算派彩;串关仅统计不参与推荐。点击方案可填入录分框。',
|
||||||
|
'settlement.smart.target_hold': '目标平台留存',
|
||||||
|
'settlement.smart.recalc': '重新计算',
|
||||||
|
'settlement.smart.apply': '采用此比分',
|
||||||
|
'settlement.smart.applied': '已填入推荐比分',
|
||||||
|
'settlement.smart.no_bets': '本场无待结算单关注单',
|
||||||
|
'settlement.smart.empty': '未找到可用方案',
|
||||||
|
'settlement.smart.meta': '单关 {singles} 笔,串关 {parlays} 笔未参与,已比对 {n} 组比分',
|
||||||
|
'settlement.smart.hold': '留存',
|
||||||
|
'settlement.smart.payout': '派彩',
|
||||||
|
'settlement.smart.win_stake': '玩家赢注占比',
|
||||||
|
'settlement.smart.wl': '赢/输单',
|
||||||
|
'settlement.smart.strategy.MIN_PAYOUT': '平台最大留存(最低派彩)',
|
||||||
|
'settlement.smart.strategy.MAX_PAYOUT': '玩家最大派彩',
|
||||||
|
'settlement.smart.strategy.BALANCED': '投注均衡(约50%赢注额)',
|
||||||
|
'settlement.smart.strategy.TARGET_HOLD': '目标留存率',
|
||||||
'msg.score_recorded': '比分已录入',
|
'msg.score_recorded': '比分已录入',
|
||||||
'msg.settlement_confirmed': '结算已确认',
|
'msg.settlement_confirmed': '结算已确认',
|
||||||
|
|
||||||
@@ -809,6 +827,24 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'settlement.est_payout': 'Est. payout',
|
'settlement.est_payout': 'Est. payout',
|
||||||
'settlement.refund_amount': 'Refund amount',
|
'settlement.refund_amount': 'Refund amount',
|
||||||
'settlement.confirm_btn': 'Confirm settlement',
|
'settlement.confirm_btn': 'Confirm settlement',
|
||||||
|
'settlement.smart.btn': 'Smart score',
|
||||||
|
'settlement.smart.title': 'Smart score suggestions',
|
||||||
|
'settlement.smart.hint': 'Enumerates valid scores from pending single bets and estimates payout. Parlays are excluded. Click a card to apply.',
|
||||||
|
'settlement.smart.target_hold': 'Target house hold',
|
||||||
|
'settlement.smart.recalc': 'Recalculate',
|
||||||
|
'settlement.smart.apply': 'Apply score',
|
||||||
|
'settlement.smart.applied': 'Score applied',
|
||||||
|
'settlement.smart.no_bets': 'No pending single bets',
|
||||||
|
'settlement.smart.empty': 'No suggestion found',
|
||||||
|
'settlement.smart.meta': 'Singles {singles}, parlays {parlays} skipped, {n} scores compared',
|
||||||
|
'settlement.smart.hold': 'Hold',
|
||||||
|
'settlement.smart.payout': 'Payout',
|
||||||
|
'settlement.smart.win_stake': 'Win stake %',
|
||||||
|
'settlement.smart.wl': 'W/L bets',
|
||||||
|
'settlement.smart.strategy.MIN_PAYOUT': 'Max house hold (min payout)',
|
||||||
|
'settlement.smart.strategy.MAX_PAYOUT': 'Max player payout',
|
||||||
|
'settlement.smart.strategy.BALANCED': 'Balanced (~50% win stake)',
|
||||||
|
'settlement.smart.strategy.TARGET_HOLD': 'Target hold rate',
|
||||||
'msg.score_recorded': 'Score saved',
|
'msg.score_recorded': 'Score saved',
|
||||||
'msg.settlement_confirmed': 'Settlement confirmed',
|
'msg.settlement_confirmed': 'Settlement confirmed',
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const preview = ref<Record<string, unknown> | null>(null);
|
|||||||
const stats = ref<SettlementBetStats | null>(null);
|
const stats = ref<SettlementBetStats | null>(null);
|
||||||
const statsLoading = ref(false);
|
const statsLoading = ref(false);
|
||||||
|
|
||||||
|
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score)
|
||||||
|
|
||||||
const matchId = computed(() => String(route.params.id ?? ''));
|
const matchId = computed(() => String(route.params.id ?? ''));
|
||||||
|
|
||||||
const statusSummary = computed(() => {
|
const statusSummary = computed(() => {
|
||||||
@@ -263,6 +265,11 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
|
<!-- 智能比分入口已关闭
|
||||||
|
<el-button size="small" type="warning" plain @click="openSmartDialog">
|
||||||
|
{{ t('settlement.smart.btn') }}
|
||||||
|
</el-button>
|
||||||
|
-->
|
||||||
<el-button size="small" @click="recordScore">{{ t('settlement.record_score') }}</el-button>
|
<el-button size="small" @click="recordScore">{{ t('settlement.record_score') }}</el-button>
|
||||||
<el-button type="primary" size="small" @click="previewSettlement">
|
<el-button type="primary" size="small" @click="previewSettlement">
|
||||||
{{ t('settlement.preview_btn') }}
|
{{ t('settlement.preview_btn') }}
|
||||||
@@ -272,6 +279,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 智能比分弹窗已关闭(见 Settlement.vue git 历史) -->
|
||||||
|
|
||||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||||
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
|
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
@@ -665,4 +674,8 @@ onMounted(() => {
|
|||||||
.confirm-btn {
|
.confirm-btn {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 智能比分弹窗样式(功能已关闭,保留便于恢复)
|
||||||
|
.smart-hint { ... }
|
||||||
|
*/
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -354,6 +354,22 @@ class ScoreDto {
|
|||||||
ftAway!: number;
|
ftAway!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 智能比分推荐已关闭
|
||||||
|
class SmartScoreSuggestDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
strategies?: Array<'MIN_PAYOUT' | 'MAX_PAYOUT' | 'BALANCED' | 'TARGET_HOLD'>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
targetHoldPct?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
maxGoals?: number;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
class MarketTemplatesDto {
|
class MarketTemplatesDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
marketTypes!: string[];
|
marketTypes!: string[];
|
||||||
@@ -1195,6 +1211,10 @@ export class AdminController {
|
|||||||
return jsonResponse(data);
|
return jsonResponse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 智能比分推荐已关闭
|
||||||
|
// @Post('matches/:id/settlement/smart-score')
|
||||||
|
// async suggestSmartScore(...) { ... }
|
||||||
|
|
||||||
@Post('matches/:id/settlement/score')
|
@Post('matches/:id/settlement/score')
|
||||||
async recordScore(
|
async recordScore(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
|||||||
@@ -354,7 +354,10 @@ export class MatchesService {
|
|||||||
const matchStats = await this.betStatsForMatches(
|
const matchStats = await this.betStatsForMatches(
|
||||||
leagueMatches.map((m) => m.id),
|
leagueMatches.map((m) => m.id),
|
||||||
);
|
);
|
||||||
const leagueBetRollup = new Map<string, MatchBetStatsSummary>();
|
const leagueBetRollup = new Map<
|
||||||
|
string,
|
||||||
|
{ betCount: number; totalStake: Decimal; pendingCount: number }
|
||||||
|
>();
|
||||||
for (const lm of leagueMatches) {
|
for (const lm of leagueMatches) {
|
||||||
const lid = lm.leagueId.toString();
|
const lid = lm.leagueId.toString();
|
||||||
const cur = leagueBetRollup.get(lid) ?? {
|
const cur = leagueBetRollup.get(lid) ?? {
|
||||||
@@ -365,7 +368,7 @@ export class MatchesService {
|
|||||||
const ms = matchStats.get(lm.id.toString());
|
const ms = matchStats.get(lm.id.toString());
|
||||||
if (ms) {
|
if (ms) {
|
||||||
cur.betCount += ms.betCount;
|
cur.betCount += ms.betCount;
|
||||||
cur.totalStake = cur.totalStake.add(ms.totalStake);
|
cur.totalStake = cur.totalStake.add(new Decimal(ms.totalStake));
|
||||||
cur.pendingCount += ms.pendingCount;
|
cur.pendingCount += ms.pendingCount;
|
||||||
}
|
}
|
||||||
leagueBetRollup.set(lid, cur);
|
leagueBetRollup.set(lid, cur);
|
||||||
@@ -417,7 +420,7 @@ export class MatchesService {
|
|||||||
]);
|
]);
|
||||||
const raw = betStatsMap.get(m.id.toString());
|
const raw = betStatsMap.get(m.id.toString());
|
||||||
const betCount = raw?.betCount ?? 0;
|
const betCount = raw?.betCount ?? 0;
|
||||||
const totalStake = raw?.totalStake.toString() ?? '0';
|
const totalStake = raw?.totalStake ?? '0';
|
||||||
const pendingBets = raw?.pendingCount ?? 0;
|
const pendingBets = raw?.pendingCount ?? 0;
|
||||||
return {
|
return {
|
||||||
id: m.id.toString(),
|
id: m.id.toString(),
|
||||||
@@ -442,11 +445,8 @@ export class MatchesService {
|
|||||||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||||||
async betStatsForMatches(
|
async betStatsForMatches(
|
||||||
matchIds: bigint[],
|
matchIds: bigint[],
|
||||||
): Promise<Map<string, MatchBetStatsSummary & { totalStake: Decimal }>> {
|
): Promise<Map<string, MatchBetStatsSummary>> {
|
||||||
const result = new Map<
|
const result = new Map<string, MatchBetStatsSummary>();
|
||||||
string,
|
|
||||||
MatchBetStatsSummary & { totalStake: Decimal }
|
|
||||||
>();
|
|
||||||
if (!matchIds.length) return result;
|
if (!matchIds.length) return result;
|
||||||
|
|
||||||
const legs = await this.prisma.betSelection.findMany({
|
const legs = await this.prisma.betSelection.findMany({
|
||||||
@@ -481,7 +481,7 @@ export class MatchesService {
|
|||||||
if (!bets) {
|
if (!bets) {
|
||||||
result.set(mid, {
|
result.set(mid, {
|
||||||
betCount: 0,
|
betCount: 0,
|
||||||
totalStake: new Decimal(0),
|
totalStake: '0',
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -494,7 +494,7 @@ export class MatchesService {
|
|||||||
}
|
}
|
||||||
result.set(mid, {
|
result.set(mid, {
|
||||||
betCount: bets.size,
|
betCount: bets.size,
|
||||||
totalStake,
|
totalStake: totalStake.toString(),
|
||||||
pendingCount,
|
pendingCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class WalletService {
|
|||||||
operatorId: bigint,
|
operatorId: bigint,
|
||||||
remark?: string,
|
remark?: string,
|
||||||
referenceId?: string,
|
referenceId?: string,
|
||||||
|
transactionType = 'MANUAL_DEPOSIT',
|
||||||
) {
|
) {
|
||||||
const amt = new Decimal(amount);
|
const amt = new Decimal(amount);
|
||||||
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
|
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
|
||||||
@@ -55,7 +56,7 @@ export class WalletService {
|
|||||||
transactionId: generateTransactionId(),
|
transactionId: generateTransactionId(),
|
||||||
userId,
|
userId,
|
||||||
walletId: w.id,
|
walletId: w.id,
|
||||||
transactionType: 'MANUAL_DEPOSIT',
|
transactionType,
|
||||||
amount: amt,
|
amount: amt,
|
||||||
balanceBefore,
|
balanceBefore,
|
||||||
balanceAfter,
|
balanceAfter,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export class CashbackService {
|
|||||||
operatorId,
|
operatorId,
|
||||||
`Cashback batch ${batch.batchNo}`,
|
`Cashback batch ${batch.batchNo}`,
|
||||||
batch.batchNo,
|
batch.batchNo,
|
||||||
|
'CASHBACK_DEPOSIT',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import {
|
|||||||
FT_CORRECT_SCORE_TEMPLATE,
|
FT_CORRECT_SCORE_TEMPLATE,
|
||||||
HT_CORRECT_SCORE_TEMPLATE,
|
HT_CORRECT_SCORE_TEMPLATE,
|
||||||
} from './domain/settlement-calculator';
|
} from './domain/settlement-calculator';
|
||||||
|
// 智能比分推荐已关闭
|
||||||
|
// import { suggestScoresForBets, type SmartScoreSimBet, type SmartScoreStrategy } from './smart-score.solver';
|
||||||
|
|
||||||
|
function resolveSelectionCodeFromLeg(
|
||||||
|
selectionCode: string | null | undefined,
|
||||||
|
nameSnapshot: string,
|
||||||
|
): string {
|
||||||
|
if (selectionCode?.trim()) return selectionCode.trim();
|
||||||
|
if (nameSnapshot.includes('-')) {
|
||||||
|
return `SCORE_${nameSnapshot.replace('-', '_')}`;
|
||||||
|
}
|
||||||
|
return nameSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettlementService {
|
export class SettlementService {
|
||||||
@@ -429,6 +442,10 @@ export class SettlementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 智能比分推荐已关闭 — 恢复时取消注释并恢复 smart-score.solver import
|
||||||
|
async suggestSmartScores(...) { ... }
|
||||||
|
*/
|
||||||
|
|
||||||
async voidMatchBets(matchId: bigint) {
|
async voidMatchBets(matchId: bigint) {
|
||||||
const bets = await this.prisma.bet.findMany({
|
const bets = await this.prisma.bet.findMany({
|
||||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||||
|
|||||||
235
apps/api/src/domains/settlement/smart-score.solver.ts
Normal file
235
apps/api/src/domains/settlement/smart-score.solver.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import {
|
||||||
|
calculatePayout,
|
||||||
|
settleSelection,
|
||||||
|
FT_CORRECT_SCORE_TEMPLATE,
|
||||||
|
HT_CORRECT_SCORE_TEMPLATE,
|
||||||
|
type ScoreInput,
|
||||||
|
} from './domain/settlement-calculator';
|
||||||
|
|
||||||
|
export type SmartScoreStrategy =
|
||||||
|
| 'MIN_PAYOUT'
|
||||||
|
| 'MAX_PAYOUT'
|
||||||
|
| 'BALANCED'
|
||||||
|
| 'TARGET_HOLD';
|
||||||
|
|
||||||
|
export type SmartScoreSimBet = {
|
||||||
|
stake: Decimal;
|
||||||
|
odds: Decimal;
|
||||||
|
marketType: string;
|
||||||
|
selectionCode: string;
|
||||||
|
handicapLine: number | null;
|
||||||
|
totalLine: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreEvaluation = {
|
||||||
|
htHome: number;
|
||||||
|
htAway: number;
|
||||||
|
ftHome: number;
|
||||||
|
ftAway: number;
|
||||||
|
totalStake: string;
|
||||||
|
totalPayout: string;
|
||||||
|
totalRefund: string;
|
||||||
|
houseProfit: string;
|
||||||
|
houseHoldPct: number;
|
||||||
|
playerWinStakePct: number;
|
||||||
|
winBets: number;
|
||||||
|
loseBets: number;
|
||||||
|
pushBets: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function templateForMarket(marketType: string): string[] {
|
||||||
|
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
|
||||||
|
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateScoreForSingleBets(
|
||||||
|
bets: SmartScoreSimBet[],
|
||||||
|
score: ScoreInput,
|
||||||
|
): Omit<ScoreEvaluation, 'htHome' | 'htAway' | 'ftHome' | 'ftAway'> & {
|
||||||
|
score: ScoreInput;
|
||||||
|
} {
|
||||||
|
let totalStake = new Decimal(0);
|
||||||
|
let totalPayout = new Decimal(0);
|
||||||
|
let totalRefund = new Decimal(0);
|
||||||
|
let winStake = new Decimal(0);
|
||||||
|
let loseStake = new Decimal(0);
|
||||||
|
let winBets = 0;
|
||||||
|
let loseBets = 0;
|
||||||
|
let pushBets = 0;
|
||||||
|
|
||||||
|
for (const bet of bets) {
|
||||||
|
totalStake = totalStake.add(bet.stake);
|
||||||
|
const result = settleSelection({
|
||||||
|
marketType: bet.marketType,
|
||||||
|
selectionCode: bet.selectionCode,
|
||||||
|
handicapLine: bet.handicapLine,
|
||||||
|
totalLine: bet.totalLine,
|
||||||
|
score,
|
||||||
|
templateScores: templateForMarket(bet.marketType),
|
||||||
|
});
|
||||||
|
const payout = calculatePayout(bet.stake, bet.odds, result);
|
||||||
|
|
||||||
|
if (result === 'LOSE') {
|
||||||
|
loseBets += 1;
|
||||||
|
loseStake = loseStake.add(bet.stake);
|
||||||
|
} else if (result === 'PUSH' || result === 'VOID') {
|
||||||
|
pushBets += 1;
|
||||||
|
totalRefund = totalRefund.add(bet.stake);
|
||||||
|
} else {
|
||||||
|
winBets += 1;
|
||||||
|
winStake = winStake.add(bet.stake);
|
||||||
|
totalPayout = totalPayout.add(payout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const houseProfit = totalStake.sub(totalPayout).sub(totalRefund);
|
||||||
|
const houseHoldPct = totalStake.gt(0)
|
||||||
|
? houseProfit.div(totalStake).mul(100).toNumber()
|
||||||
|
: 0;
|
||||||
|
const playerWinStakePct = totalStake.gt(0)
|
||||||
|
? winStake.div(totalStake).mul(100).toNumber()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
totalStake: totalStake.toString(),
|
||||||
|
totalPayout: totalPayout.toString(),
|
||||||
|
totalRefund: totalRefund.toString(),
|
||||||
|
houseProfit: houseProfit.toString(),
|
||||||
|
houseHoldPct: Math.round(houseHoldPct * 100) / 100,
|
||||||
|
playerWinStakePct: Math.round(playerWinStakePct * 100) / 100,
|
||||||
|
winBets,
|
||||||
|
loseBets,
|
||||||
|
pushBets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function strategyScore(
|
||||||
|
evalResult: ReturnType<typeof evaluateScoreForSingleBets>,
|
||||||
|
strategy: SmartScoreStrategy,
|
||||||
|
targetHoldPct: number,
|
||||||
|
): number {
|
||||||
|
const payout = new Decimal(evalResult.totalPayout).add(evalResult.totalRefund);
|
||||||
|
const hold = evalResult.houseHoldPct;
|
||||||
|
const winPct = evalResult.playerWinStakePct;
|
||||||
|
|
||||||
|
switch (strategy) {
|
||||||
|
case 'MIN_PAYOUT':
|
||||||
|
return payout.toNumber();
|
||||||
|
case 'MAX_PAYOUT':
|
||||||
|
return -payout.toNumber();
|
||||||
|
case 'BALANCED':
|
||||||
|
return Math.abs(winPct - 50);
|
||||||
|
case 'TARGET_HOLD':
|
||||||
|
return Math.abs(hold - targetHoldPct);
|
||||||
|
default:
|
||||||
|
return payout.toNumber();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreKey(s: ScoreInput): string {
|
||||||
|
return `${s.htHome}-${s.htAway}-${s.ftHome}-${s.ftAway}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestScoresForBets(
|
||||||
|
bets: SmartScoreSimBet[],
|
||||||
|
options: {
|
||||||
|
strategies?: SmartScoreStrategy[];
|
||||||
|
targetHoldPct?: number;
|
||||||
|
maxGoals?: number;
|
||||||
|
alternativesPerStrategy?: number;
|
||||||
|
} = {},
|
||||||
|
): {
|
||||||
|
suggestions: Array<{
|
||||||
|
strategy: SmartScoreStrategy;
|
||||||
|
rank: number;
|
||||||
|
evaluation: ScoreEvaluation;
|
||||||
|
}>;
|
||||||
|
searchedCandidates: number;
|
||||||
|
} {
|
||||||
|
const strategies = options.strategies ?? [
|
||||||
|
'MIN_PAYOUT',
|
||||||
|
'MAX_PAYOUT',
|
||||||
|
'BALANCED',
|
||||||
|
'TARGET_HOLD',
|
||||||
|
];
|
||||||
|
const targetHoldPct = Math.min(100, Math.max(0, options.targetHoldPct ?? 30));
|
||||||
|
const maxGoals = Math.min(8, Math.max(2, options.maxGoals ?? 5));
|
||||||
|
|
||||||
|
if (!bets.length) {
|
||||||
|
const zero: ScoreInput = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
|
||||||
|
const ev = evaluateScoreForSingleBets(bets, zero);
|
||||||
|
return {
|
||||||
|
suggestions: strategies.map((strategy, i) => ({
|
||||||
|
strategy,
|
||||||
|
rank: 1,
|
||||||
|
evaluation: {
|
||||||
|
htHome: 0,
|
||||||
|
htAway: 0,
|
||||||
|
ftHome: 0,
|
||||||
|
ftAway: 0,
|
||||||
|
...ev,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
searchedCandidates: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEvals: ReturnType<typeof evaluateScoreForSingleBets>[] = [];
|
||||||
|
for (let htHome = 0; htHome <= maxGoals; htHome++) {
|
||||||
|
for (let htAway = 0; htAway <= maxGoals; htAway++) {
|
||||||
|
for (let ftHome = htHome; ftHome <= maxGoals; ftHome++) {
|
||||||
|
for (let ftAway = htAway; ftAway <= maxGoals; ftAway++) {
|
||||||
|
const score: ScoreInput = { htHome, htAway, ftHome, ftAway };
|
||||||
|
allEvals.push(evaluateScoreForSingleBets(bets, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: Array<{
|
||||||
|
strategy: SmartScoreStrategy;
|
||||||
|
rank: number;
|
||||||
|
evaluation: ScoreEvaluation;
|
||||||
|
}> = [];
|
||||||
|
const usedKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const strategy of strategies) {
|
||||||
|
const sorted = [...allEvals].sort(
|
||||||
|
(a, b) =>
|
||||||
|
strategyScore(a, strategy, targetHoldPct) -
|
||||||
|
strategyScore(b, strategy, targetHoldPct),
|
||||||
|
);
|
||||||
|
let rank = 0;
|
||||||
|
for (const ev of sorted) {
|
||||||
|
const key = scoreKey(ev.score);
|
||||||
|
if (usedKeys.has(`${strategy}:${key}`)) continue;
|
||||||
|
usedKeys.add(`${strategy}:${key}`);
|
||||||
|
rank += 1;
|
||||||
|
suggestions.push({
|
||||||
|
strategy,
|
||||||
|
rank,
|
||||||
|
evaluation: {
|
||||||
|
htHome: ev.score.htHome,
|
||||||
|
htAway: ev.score.htAway,
|
||||||
|
ftHome: ev.score.ftHome,
|
||||||
|
ftAway: ev.score.ftAway,
|
||||||
|
totalStake: ev.totalStake,
|
||||||
|
totalPayout: ev.totalPayout,
|
||||||
|
totalRefund: ev.totalRefund,
|
||||||
|
houseProfit: ev.houseProfit,
|
||||||
|
houseHoldPct: ev.houseHoldPct,
|
||||||
|
playerWinStakePct: ev.playerWinStakePct,
|
||||||
|
winBets: ev.winBets,
|
||||||
|
loseBets: ev.loseBets,
|
||||||
|
pushBets: ev.pushBets,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (rank >= (options.alternativesPerStrategy ?? 1)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suggestions, searchedCandidates: allEvals.length };
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ const i18n = createI18n({
|
|||||||
tx_bet_push: '投注退水',
|
tx_bet_push: '投注退水',
|
||||||
tx_bet_refund: '投注退款',
|
tx_bet_refund: '投注退款',
|
||||||
tx_bet_void: '投注撤销',
|
tx_bet_void: '投注撤销',
|
||||||
tx_cashback: '返水',
|
tx_cashback: '返水发放',
|
||||||
tx_resettle: '重新结算',
|
tx_resettle: '重新结算',
|
||||||
},
|
},
|
||||||
bet: {
|
bet: {
|
||||||
@@ -288,7 +288,7 @@ const i18n = createI18n({
|
|||||||
tx_bet_push: 'Bet Push',
|
tx_bet_push: 'Bet Push',
|
||||||
tx_bet_refund: 'Bet Refund',
|
tx_bet_refund: 'Bet Refund',
|
||||||
tx_bet_void: 'Bet Voided',
|
tx_bet_void: 'Bet Voided',
|
||||||
tx_cashback: 'Cashback',
|
tx_cashback: 'Cashback Distribution',
|
||||||
tx_resettle: 'Resettlement',
|
tx_resettle: 'Resettlement',
|
||||||
},
|
},
|
||||||
bet: {
|
bet: {
|
||||||
@@ -519,7 +519,7 @@ const i18n = createI18n({
|
|||||||
tx_bet_push: 'Pertaruhan Seri',
|
tx_bet_push: 'Pertaruhan Seri',
|
||||||
tx_bet_refund: 'Bayaran Balik',
|
tx_bet_refund: 'Bayaran Balik',
|
||||||
tx_bet_void: 'Pertaruhan Dibatalkan',
|
tx_bet_void: 'Pertaruhan Dibatalkan',
|
||||||
tx_cashback: 'Cashback',
|
tx_cashback: 'Pembayaran Cashback',
|
||||||
tx_resettle: 'Penyelesaian Semula',
|
tx_resettle: 'Penyelesaian Semula',
|
||||||
},
|
},
|
||||||
bet: {
|
bet: {
|
||||||
|
|||||||
@@ -623,7 +623,6 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.vs-img { animation: none; filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35)); }
|
|
||||||
.hz-path, .hz-beam { animation: none; opacity: 0; }
|
.hz-path, .hz-beam { animation: none; opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const TX_KEY_MAP: Record<string, string> = {
|
|||||||
BET_VOID: 'wallet.tx_bet_void',
|
BET_VOID: 'wallet.tx_bet_void',
|
||||||
BET_VOID_REFUND: 'wallet.tx_bet_void',
|
BET_VOID_REFUND: 'wallet.tx_bet_void',
|
||||||
CASHBACK: 'wallet.tx_cashback',
|
CASHBACK: 'wallet.tx_cashback',
|
||||||
|
CASHBACK_DEPOSIT: 'wallet.tx_cashback',
|
||||||
RESETTLE_REVERSE: 'wallet.tx_resettle',
|
RESETTLE_REVERSE: 'wallet.tx_resettle',
|
||||||
DEPOSIT: 'wallet.tx_deposit',
|
DEPOSIT: 'wallet.tx_deposit',
|
||||||
WITHDRAW: 'wallet.tx_withdraw',
|
WITHDRAW: 'wallet.tx_withdraw',
|
||||||
@@ -45,7 +46,6 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="section-title">{{ t('nav.wallet') }}</h2>
|
|
||||||
<div v-if="transactions.length" class="card">
|
<div v-if="transactions.length" class="card">
|
||||||
<div
|
<div
|
||||||
v-for="tx in transactions"
|
v-for="tx in transactions"
|
||||||
|
|||||||
Reference in New Issue
Block a user