fix: deduplicate recommendations and widen position sizing range
- Add dedup check in recommendation worker: skip generation when latest rec for same ticker+window has identical action/mode/confidence - Widen position sizing range (1-10% portfolio, 0.3-2% max loss) and factor in trend strength + evidence count for differentiated sizing - API returns only latest recommendation per ticker by default (DISTINCT ON) to eliminate duplicate rows in the frontend list view
This commit is contained in:
@@ -656,6 +656,64 @@ async def fetch_latest_recommendations(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_DEDUP_CHECK_QUERY = """
|
||||
SELECT r.id, r.confidence, r.action, r.mode, r.generated_at
|
||||
FROM recommendations r
|
||||
WHERE r.ticker = $1
|
||||
AND r.time_horizon LIKE $2 || '%'
|
||||
ORDER BY r.generated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
|
||||
async def _is_duplicate_recommendation(
|
||||
pool: asyncpg.Pool,
|
||||
ticker: str,
|
||||
summary: TrendSummary,
|
||||
result: EligibilityResult,
|
||||
) -> bool:
|
||||
"""Check if the latest recommendation for this ticker+window is effectively identical.
|
||||
|
||||
Compares confidence, action, and mode. If they match, the underlying
|
||||
trend data hasn't changed meaningfully and we skip regeneration.
|
||||
"""
|
||||
horizon_prefix = _map_time_horizon_prefix(summary.window.value)
|
||||
row = await pool.fetchrow(_DEDUP_CHECK_QUERY, ticker, horizon_prefix)
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
# If the previous recommendation has the same action, mode, and confidence
|
||||
# (within a small tolerance), it's a duplicate
|
||||
prev_confidence = float(row["confidence"])
|
||||
prev_action = row["action"]
|
||||
prev_mode = row["mode"]
|
||||
|
||||
same_action = prev_action == result.action.value
|
||||
same_mode = prev_mode == result.mode.value
|
||||
same_confidence = abs(prev_confidence - summary.confidence) < 0.01
|
||||
|
||||
if same_action and same_mode and same_confidence:
|
||||
logger.info(
|
||||
"Skipping duplicate recommendation for %s — action=%s mode=%s "
|
||||
"confidence=%.3f matches previous",
|
||||
ticker, prev_action, prev_mode, prev_confidence,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _map_time_horizon_prefix(window: str) -> str:
|
||||
"""Map window to the time_horizon prefix for dedup matching."""
|
||||
mapping = {
|
||||
"intraday": "intraday",
|
||||
"1d": "swing_1d",
|
||||
"7d": "swing_1d",
|
||||
"30d": "position_10d",
|
||||
"90d": "position_30d",
|
||||
}
|
||||
return mapping.get(window, "window_")
|
||||
|
||||
|
||||
async def generate_recommendation(
|
||||
pool: asyncpg.Pool,
|
||||
ticker: str,
|
||||
@@ -670,6 +728,7 @@ async def generate_recommendation(
|
||||
|
||||
Steps:
|
||||
1. Fetch the latest trend summary for the ticker + window.
|
||||
1b. Skip if the latest recommendation for this ticker is effectively identical.
|
||||
2. Fetch the latest trend projection (Requirement 12.8, 12.9).
|
||||
3. Evaluate data quality suppression (Requirement 7.4).
|
||||
4. Evaluate eligibility using deterministic rules.
|
||||
@@ -678,7 +737,8 @@ async def generate_recommendation(
|
||||
rewritten into analyst-quality prose via the LLM wording layer.
|
||||
6. Persist the recommendation and evidence citations.
|
||||
|
||||
Returns the Recommendation, or None if no trend data exists.
|
||||
Returns the Recommendation, or None if no trend data exists or recommendation
|
||||
is a duplicate of the most recent one.
|
||||
"""
|
||||
if reference_time is None:
|
||||
reference_time = datetime.now(timezone.utc)
|
||||
@@ -692,6 +752,11 @@ async def generate_recommendation(
|
||||
logger.info("No trend data for %s/%s — skipping recommendation", ticker, window)
|
||||
return None
|
||||
|
||||
# 1b. Check for duplicate: evaluate eligibility early for dedup comparison
|
||||
preliminary_result = evaluate_eligibility(summary, cfg)
|
||||
if await _is_duplicate_recommendation(pool, ticker, summary, preliminary_result):
|
||||
return None
|
||||
|
||||
# 2. Fetch latest trend projection (Requirement 12.8, 12.9)
|
||||
projection = await fetch_latest_projection(pool, ticker, window)
|
||||
# Exclude low-confidence projections from influencing recommendation
|
||||
@@ -708,8 +773,8 @@ async def generate_recommendation(
|
||||
summary, quality_ctx=quality_ctx, config=sup_cfg, reference_time=reference_time,
|
||||
)
|
||||
|
||||
# 4. Evaluate eligibility
|
||||
result = evaluate_eligibility(summary, cfg)
|
||||
# 4. Evaluate eligibility (use preliminary result, already computed for dedup)
|
||||
result = preliminary_result
|
||||
|
||||
# Apply suppression: force mode to informational if suppressed
|
||||
if suppression.suppressed:
|
||||
|
||||
Reference in New Issue
Block a user