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:
Celes Renata
2026-04-17 00:15:32 +00:00
parent 29f46d387c
commit f11aa0a1ee
3 changed files with 134 additions and 35 deletions
+68 -3
View File
@@ -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: