feat: implement automatic margin pricing and margin reverse engineering in sync
This commit is contained in:
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_UP
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
@@ -66,25 +66,16 @@ def calculate_target_rate(values: list[Any], percent: Any = 0, algorithm: str =
|
|||||||
elif algorithm == "max_plus_percent":
|
elif algorithm == "max_plus_percent":
|
||||||
base = max(rates)
|
base = max(rates)
|
||||||
elif algorithm == "priority_weighted_plus_percent":
|
elif algorithm == "priority_weighted_plus_percent":
|
||||||
rates_sorted = sorted(rates)
|
min_rate = min(rates)
|
||||||
N = len(rates_sorted)
|
max_rate = max(rates)
|
||||||
if N == 1:
|
raw_target = max(min_rate * Decimal("1.50"), max_rate * Decimal("1.10"))
|
||||||
base = rates_sorted[0]
|
return raw_target.quantize(Decimal("0.01"), rounding=ROUND_UP)
|
||||||
else:
|
|
||||||
w1 = Decimal("0.85")
|
|
||||||
w_others = Decimal("0.15") / Decimal(N - 1)
|
|
||||||
base = rates_sorted[0] * w1 + sum(rates_sorted[1:], Decimal("0")) * w_others
|
|
||||||
else:
|
else:
|
||||||
raise WebsiteError(f"不支持的算法:{algorithm}")
|
raise WebsiteError(f"不支持的算法:{algorithm}")
|
||||||
pct = Decimal(str(percent or 0))
|
pct = Decimal(str(percent or 0))
|
||||||
if pct < 0:
|
if pct < 0:
|
||||||
raise WebsiteError("百分比不能为负数")
|
raise WebsiteError("百分比不能为负数")
|
||||||
target_rate = (base * (Decimal("1") + pct / Decimal("100"))).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
return (base * (Decimal("1") + pct / Decimal("100"))).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
if algorithm == "priority_weighted_plus_percent":
|
|
||||||
min_rate_2d = min(rates).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
||||||
if target_rate < min_rate_2d:
|
|
||||||
target_rate = min_rate_2d
|
|
||||||
return target_rate
|
|
||||||
|
|
||||||
|
|
||||||
def _unwrap_data(value: Any) -> Any:
|
def _unwrap_data(value: Any) -> Any:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ from app.models.upstream import Upstream
|
|||||||
from app.models.upstream_key import UpstreamGeneratedKey
|
from app.models.upstream_key import UpstreamGeneratedKey
|
||||||
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
|
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
|
||||||
from app.services.auth_config import normalize_auth_config
|
from app.services.auth_config import normalize_auth_config
|
||||||
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate
|
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate, parse_positive_decimal
|
||||||
from app.services.upstream_client import UpstreamClient
|
from app.services.upstream_client import UpstreamClient
|
||||||
from app.services import webhook_service
|
from app.services import webhook_service
|
||||||
from app.utils.number import fixed_decimal_string
|
from app.utils.number import fixed_decimal_string
|
||||||
@@ -168,6 +168,21 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True)
|
|||||||
})
|
})
|
||||||
try:
|
try:
|
||||||
target_rate = calculate_target_rate([item.get("rate") for item in source_rates], binding.percent, binding.algorithm)
|
target_rate = calculate_target_rate([item.get("rate") for item in source_rates], binding.percent, binding.algorithm)
|
||||||
|
if binding.algorithm == "priority_weighted_plus_percent":
|
||||||
|
rates = [rate for rate in (parse_positive_decimal(item.get("rate")) for item in source_rates) if rate is not None]
|
||||||
|
if rates:
|
||||||
|
rates_sorted = sorted(rates)
|
||||||
|
N = len(rates_sorted)
|
||||||
|
if N == 1:
|
||||||
|
base = rates_sorted[0]
|
||||||
|
else:
|
||||||
|
w1 = Decimal("0.85")
|
||||||
|
w_others = Decimal("0.15") / Decimal(N - 1)
|
||||||
|
base = rates_sorted[0] * w1 + sum(rates_sorted[1:], Decimal("0")) * w_others
|
||||||
|
|
||||||
|
if base > 0:
|
||||||
|
effective_percent = (target_rate / base - Decimal("1")) * Decimal("100")
|
||||||
|
binding.percent = fixed_decimal_string(effective_percent.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), 2)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _log(db, binding, website, source_rates, "failed", str(exc))
|
return _log(db, binding, website, source_rates, "failed", str(exc))
|
||||||
|
|
||||||
|
|||||||
@@ -387,3 +387,49 @@ def test_create_binding_does_not_notify_when_website_rate_unchanged(monkeypatch,
|
|||||||
|
|
||||||
assert result.target_group_id == "target"
|
assert result.target_group_id == "target"
|
||||||
assert db_session.query(NotificationLog).count() == 0
|
assert db_session.query(NotificationLog).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_binding_auto_percent_writeback(monkeypatch, db_session):
|
||||||
|
website, upstream = seed_rows(db_session)
|
||||||
|
binding = WebsiteGroupBinding(
|
||||||
|
website_id=website.id,
|
||||||
|
target_group_id="target",
|
||||||
|
target_group_name="Target group",
|
||||||
|
source_groups_json=json.dumps([{
|
||||||
|
"upstream_id": upstream.id,
|
||||||
|
"upstream_name": "Upstream",
|
||||||
|
"group_id": "source",
|
||||||
|
"group_name": "Source",
|
||||||
|
}], ensure_ascii=False),
|
||||||
|
percent="0.00",
|
||||||
|
algorithm="priority_weighted_plus_percent",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
db_session.add(binding)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(binding)
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
def get_groups(self, endpoint):
|
||||||
|
return [{"id": "target", "name": "Target group", "rate_multiplier": "2.0"}]
|
||||||
|
def update_group_rate(self, endpoint, group_id, rate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
|
||||||
|
monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", FakeClient)
|
||||||
|
|
||||||
|
from app.services.website_sync import sync_binding
|
||||||
|
sync_binding(db_session, binding, write=True)
|
||||||
|
|
||||||
|
db_session.refresh(binding)
|
||||||
|
assert binding.percent == "50.00"
|
||||||
|
|
||||||
|
log = db_session.query(WebsiteSyncLog).filter(WebsiteSyncLog.binding_id == binding.id).one()
|
||||||
|
assert log.percent == "50.00"
|
||||||
|
assert log.new_rate == "3.00"
|
||||||
|
|||||||
@@ -402,21 +402,18 @@ def test_list_accounts_pagination_returns_none_when_any_page_fails():
|
|||||||
|
|
||||||
|
|
||||||
def test_calculate_target_rate_priority_weighted():
|
def test_calculate_target_rate_priority_weighted():
|
||||||
# 0.18、0.25 + 50% 应得到约 0.29
|
# 0.18、0.25 应当至少满足 min * 1.50 (0.27) 和 max * 1.10 (0.275),向上取整为 0.28,忽略 percent 输入
|
||||||
assert calculate_target_rate(["0.18", "0.25"], 50, "priority_weighted_plus_percent") == Decimal("0.29")
|
assert calculate_target_rate(["0.18", "0.25"], 50, "priority_weighted_plus_percent") == Decimal("0.28")
|
||||||
# 单来源时等同于该来源倍率 + 百分比
|
assert calculate_target_rate(["0.18", "0.25"], 0, "priority_weighted_plus_percent") == Decimal("0.28")
|
||||||
assert calculate_target_rate(["0.18"], 50, "priority_weighted_plus_percent") == Decimal("0.27")
|
|
||||||
# 多来源时最低倍率占 85%,其余平分 15%:
|
|
||||||
# 0.18 * 0.85 + (0.25 + 0.38) * 0.075 = 0.20025 -> 0.20025 * 1.5 = 0.300375 -> 0.30
|
|
||||||
assert calculate_target_rate(["0.18", "0.25", "0.38"], 50, "priority_weighted_plus_percent") == Decimal("0.30")
|
|
||||||
|
|
||||||
# 4 个来源以锁定均分 15% 的权重语义:
|
# 单来源 0.05 自动得到 0.08 (max(0.05 * 1.50, 0.05 * 1.10) = 0.075 -> ROUND_UP -> 0.08)
|
||||||
# 0.18 * 0.85 + (0.24 + 0.30 + 0.36) * 0.05 = 0.198 -> 0.198 * 1.5 = 0.297 -> 0.30
|
assert calculate_target_rate(["0.05"], 0, "priority_weighted_plus_percent") == Decimal("0.08")
|
||||||
assert calculate_target_rate(["0.18", "0.24", "0.30", "0.36"], 50, "priority_weighted_plus_percent") == Decimal("0.30")
|
|
||||||
assert calculate_target_rate(["0.18", "0.24", "0.30", "0.36"], 0, "priority_weighted_plus_percent") == Decimal("0.20")
|
|
||||||
|
|
||||||
# 验证保护线与精度保护:最低来源 0.124,应当四舍五入并返回两位小数
|
# 4 个来源:min=0.18, max=0.36. max(0.27, 0.396) = 0.396 -> ROUND_UP -> 0.40
|
||||||
assert calculate_target_rate(["0.124"], 0, "priority_weighted_plus_percent") == Decimal("0.12")
|
assert calculate_target_rate(["0.18", "0.24", "0.30", "0.36"], 50, "priority_weighted_plus_percent") == Decimal("0.40")
|
||||||
|
|
||||||
|
# 验证保护线与精度保护:最低来源 0.124。max(0.186, 0.1364) = 0.186 -> ROUND_UP -> 0.19
|
||||||
|
assert calculate_target_rate(["0.124"], 0, "priority_weighted_plus_percent") == Decimal("0.19")
|
||||||
|
|
||||||
# 无可用正数倍率时报错
|
# 无可用正数倍率时报错
|
||||||
with pytest.raises(WebsiteError, match="没有可用的正数上游倍率"):
|
with pytest.raises(WebsiteError, match="没有可用的正数上游倍率"):
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
<div class="binding-main">
|
<div class="binding-main">
|
||||||
<div class="binding-title">{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}</div>
|
<div class="binding-title">{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}</div>
|
||||||
<div class="binding-meta">
|
<div class="binding-meta">
|
||||||
{{ algorithmLabel(binding.algorithm) }} + {{ binding.percent }}% ·
|
{{ binding.algorithm === 'priority_weighted_plus_percent' ? `${algorithmLabel(binding.algorithm)} · 自动约 ${binding.percent}%` : `${algorithmLabel(binding.algorithm)} + ${binding.percent}%` }} ·
|
||||||
{{ binding.source_groups.length }} 个上游分组
|
{{ binding.source_groups.length }} 个上游分组
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,8 +297,8 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="百分比">
|
<el-form-item label="百分比">
|
||||||
<div class="custom-percent-stepper">
|
<div class="custom-percent-stepper" :class="{ disabled: bindingForm.algorithm === 'priority_weighted_plus_percent' }">
|
||||||
<button type="button" @click="adjustPercent(-5)" class="stepper-btn">
|
<button type="button" @click="adjustPercent(-5)" class="stepper-btn" :disabled="bindingForm.algorithm === 'priority_weighted_plus_percent'">
|
||||||
<el-icon><Minus /></el-icon>
|
<el-icon><Minus /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
@@ -308,8 +308,10 @@
|
|||||||
max="100"
|
max="100"
|
||||||
v-model.number="bindingForm.percent"
|
v-model.number="bindingForm.percent"
|
||||||
class="stepper-input"
|
class="stepper-input"
|
||||||
|
:disabled="bindingForm.algorithm === 'priority_weighted_plus_percent'"
|
||||||
|
:placeholder="bindingForm.algorithm === 'priority_weighted_plus_percent' ? '自动计算' : ''"
|
||||||
/>
|
/>
|
||||||
<button type="button" @click="adjustPercent(5)" class="stepper-btn">
|
<button type="button" @click="adjustPercent(5)" class="stepper-btn" :disabled="bindingForm.algorithm === 'priority_weighted_plus_percent'">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2588,6 +2590,27 @@ onMounted(loadAll)
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-percent-stepper.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
background: #0f0706;
|
||||||
|
border-color: #2b1c18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-percent-stepper.disabled .stepper-btn {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: #5c4a45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-percent-stepper.disabled .stepper-btn:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: #5c4a45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-percent-stepper.disabled .stepper-input {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: #8c746b;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom Switch Toggle */
|
/* Custom Switch Toggle */
|
||||||
.switch-toggle-line {
|
.switch-toggle-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user