feat: competitive intelligence & historical pattern matching layer
This commit is contained in:
@@ -32,6 +32,8 @@ class SuppressionReason(str, Enum):
|
||||
LOW_SOURCE_DIVERSITY = "low_source_diversity"
|
||||
HIGH_EXTRACTION_FAILURE_RATE = "high_extraction_failure_rate"
|
||||
INSUFFICIENT_VALID_DOCUMENTS = "insufficient_valid_documents"
|
||||
MACRO_ONLY_SIGNAL = "macro_only_signal"
|
||||
PATTERN_ONLY_SIGNAL = "pattern_only_signal"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -240,3 +242,116 @@ def evaluate_suppression(
|
||||
data_quality_score=quality_score,
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Macro-only suppression (Requirements: 10.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MACRO_ONLY_CAVEAT = (
|
||||
"[Macro-only signal] This trend direction is driven solely by macro/geopolitical "
|
||||
"signals with no supporting company-specific evidence. Recommendation is "
|
||||
"informational only and should not be used for automated trading decisions."
|
||||
)
|
||||
|
||||
|
||||
def evaluate_macro_only_suppression(
|
||||
summary: TrendSummary,
|
||||
macro_signal_count: int,
|
||||
company_signal_count: int,
|
||||
) -> bool:
|
||||
"""Evaluate whether a recommendation should be suppressed due to macro-only signals.
|
||||
|
||||
When macro signals are the sole basis for a trend direction change
|
||||
(no supporting company-specific signals), the recommendation should
|
||||
be forced to informational mode with a macro-only caveat.
|
||||
|
||||
Args:
|
||||
summary: The trend summary to evaluate.
|
||||
macro_signal_count: Number of macro signals contributing to the trend.
|
||||
company_signal_count: Number of company-specific signals contributing.
|
||||
|
||||
Returns:
|
||||
True if the recommendation should be suppressed (macro-only), False otherwise.
|
||||
|
||||
Requirements: 10.3
|
||||
"""
|
||||
# No macro signals means no macro-only suppression
|
||||
if macro_signal_count <= 0:
|
||||
return False
|
||||
|
||||
# If there are company-specific signals, no suppression needed
|
||||
if company_signal_count > 0:
|
||||
return False
|
||||
|
||||
# Macro signals are the sole basis — suppress
|
||||
logger.info(
|
||||
"Macro-only suppression triggered for %s/%s: "
|
||||
"macro_signals=%d, company_signals=%d, direction=%s",
|
||||
summary.entity_id,
|
||||
summary.window.value,
|
||||
macro_signal_count,
|
||||
company_signal_count,
|
||||
summary.trend_direction.value,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern-only suppression (Requirements: 9.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PATTERN_ONLY_CAVEAT = (
|
||||
"[Pattern-only signal] This trend direction is driven solely by historical "
|
||||
"pattern and competitive signals with no supporting company-specific or macro "
|
||||
"evidence. Recommendation is informational only."
|
||||
)
|
||||
|
||||
|
||||
def evaluate_pattern_only_suppression(
|
||||
summary: TrendSummary,
|
||||
pattern_signal_count: int,
|
||||
company_signal_count: int,
|
||||
macro_signal_count: int,
|
||||
) -> bool:
|
||||
"""Evaluate whether a recommendation should be suppressed due to pattern-only signals.
|
||||
|
||||
When pattern-based signals are the sole basis for a trend direction change
|
||||
(no supporting company-specific or macro signals), the recommendation should
|
||||
be forced to informational mode with a pattern-only caveat.
|
||||
|
||||
Args:
|
||||
summary: The trend summary to evaluate.
|
||||
pattern_signal_count: Number of pattern/competitive signals contributing.
|
||||
company_signal_count: Number of company-specific signals contributing.
|
||||
macro_signal_count: Number of macro signals contributing.
|
||||
|
||||
Returns:
|
||||
True if the recommendation should be suppressed (pattern-only), False otherwise.
|
||||
|
||||
Requirements: 9.3
|
||||
"""
|
||||
# No pattern signals means no pattern-only suppression
|
||||
if pattern_signal_count <= 0:
|
||||
return False
|
||||
|
||||
# If there are company-specific signals, no suppression needed
|
||||
if company_signal_count > 0:
|
||||
return False
|
||||
|
||||
# If there are macro signals, no suppression needed
|
||||
if macro_signal_count > 0:
|
||||
return False
|
||||
|
||||
# Pattern signals are the sole basis — suppress
|
||||
logger.info(
|
||||
"Pattern-only suppression triggered for %s/%s: "
|
||||
"pattern_signals=%d, company_signals=%d, macro_signals=%d, direction=%s",
|
||||
summary.entity_id,
|
||||
summary.window.value,
|
||||
pattern_signal_count,
|
||||
company_signal_count,
|
||||
macro_signal_count,
|
||||
summary.trend_direction.value,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -31,6 +31,7 @@ from services.recommendation.thesis_llm import (
|
||||
THESIS_PROMPT_VERSION,
|
||||
rewrite_thesis_with_llm,
|
||||
)
|
||||
from services.aggregation.projection import TrendProjection
|
||||
from services.shared.config import OllamaConfig
|
||||
from services.shared.metrics import (
|
||||
RECOMMENDATION_CONFIDENCE,
|
||||
@@ -178,6 +179,63 @@ async def fetch_latest_trend(
|
||||
return _parse_trend_row(row)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch latest trend projection for a ticker + window
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LATEST_PROJECTION_QUERY = """
|
||||
SELECT
|
||||
tp.projected_direction, tp.projected_strength, tp.projected_confidence,
|
||||
tp.projection_horizon, tp.driving_factors, tp.macro_contribution_pct,
|
||||
tp.diverges_from_current, tp.computed_at
|
||||
FROM trend_projections tp
|
||||
JOIN trend_windows tw ON tw.id = tp.trend_window_id
|
||||
WHERE tw.entity_id = $1 AND tw."window" = $2
|
||||
ORDER BY tp.computed_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
|
||||
async def fetch_latest_projection(
|
||||
pool: asyncpg.Pool,
|
||||
ticker: str,
|
||||
window: str,
|
||||
) -> TrendProjection | None:
|
||||
"""Fetch the most recent trend projection for a ticker and window.
|
||||
|
||||
Returns None if no projection exists. Low-confidence projections
|
||||
are returned with low_confidence=True so callers can decide whether
|
||||
to use them (Requirement 12.9).
|
||||
"""
|
||||
try:
|
||||
row = await pool.fetchrow(_LATEST_PROJECTION_QUERY, ticker, window)
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
driving_factors = row["driving_factors"]
|
||||
if isinstance(driving_factors, str):
|
||||
driving_factors = json.loads(driving_factors)
|
||||
|
||||
proj = TrendProjection(
|
||||
projected_direction=row["projected_direction"],
|
||||
projected_strength=float(row["projected_strength"]),
|
||||
projected_confidence=float(row["projected_confidence"]),
|
||||
projection_horizon=row["projection_horizon"],
|
||||
driving_factors=driving_factors or [],
|
||||
macro_contribution_pct=float(row["macro_contribution_pct"] or 0.0),
|
||||
diverges_from_current=bool(row["diverges_from_current"]),
|
||||
computed_at=row["computed_at"],
|
||||
low_confidence=float(row["projected_confidence"]) < 0.3,
|
||||
)
|
||||
return proj
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to fetch projection for %s/%s — continuing without projection",
|
||||
ticker, window, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build thesis from trend summary (deterministic, no LLM)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -186,11 +244,16 @@ async def fetch_latest_trend(
|
||||
def build_thesis(
|
||||
summary: TrendSummary,
|
||||
result: EligibilityResult,
|
||||
projection: TrendProjection | None = None,
|
||||
) -> str:
|
||||
"""Generate a deterministic thesis string from trend data.
|
||||
|
||||
This is the descriptive analysis portion (Requirement 7.2).
|
||||
The LLM wording layer is a separate optional task.
|
||||
|
||||
When a TrendProjection is provided and is not low-confidence,
|
||||
the thesis incorporates the projected direction and key driving
|
||||
factors (Requirement 12.8).
|
||||
"""
|
||||
direction = summary.trend_direction.value
|
||||
ticker = summary.entity_id
|
||||
@@ -218,6 +281,27 @@ def build_thesis(
|
||||
+ f"(contradiction score: {summary.contradiction_score:.2f})."
|
||||
)
|
||||
|
||||
# Trend projection (Requirement 12.8)
|
||||
if projection is not None and not projection.low_confidence:
|
||||
proj_dir = projection.projected_direction
|
||||
proj_str = projection.projected_strength
|
||||
parts.append(
|
||||
f"Forward projection ({projection.projection_horizon}): "
|
||||
f"{proj_dir} at strength {proj_str:.2f}."
|
||||
)
|
||||
# Include top driving factors
|
||||
non_divergence_factors = [
|
||||
f for f in projection.driving_factors
|
||||
if not f.startswith("DIVERGENCE:")
|
||||
]
|
||||
if non_divergence_factors:
|
||||
factors_str = "; ".join(non_divergence_factors[:2])
|
||||
parts.append(f"Key drivers: {factors_str}.")
|
||||
if projection.diverges_from_current:
|
||||
parts.append(
|
||||
f"Note: projection diverges from current {direction} trend."
|
||||
)
|
||||
|
||||
# Risks
|
||||
if summary.material_risks:
|
||||
risk_str = "; ".join(summary.material_risks[:2])
|
||||
@@ -290,6 +374,7 @@ def build_recommendation(
|
||||
reference_time: datetime | None = None,
|
||||
llm_thesis: str | None = None,
|
||||
suppression_result: SuppressionResult | None = None,
|
||||
projection: TrendProjection | None = None,
|
||||
) -> Recommendation:
|
||||
"""Assemble a Recommendation object from a trend summary and eligibility result.
|
||||
|
||||
@@ -302,6 +387,10 @@ def build_recommendation(
|
||||
|
||||
If ``suppression_result`` indicates suppression, a suppression note
|
||||
is appended to the thesis for audit visibility (Requirement 7.4).
|
||||
|
||||
If ``projection`` is provided and is not low-confidence, the thesis
|
||||
incorporates projected direction and driving factors (Requirement 12.8).
|
||||
The time_horizon may be refined based on the projection horizon.
|
||||
"""
|
||||
if reference_time is None:
|
||||
reference_time = datetime.now(timezone.utc)
|
||||
@@ -309,7 +398,7 @@ def build_recommendation(
|
||||
# Combine evidence refs — supporting first, then opposing
|
||||
evidence_refs = list(summary.top_supporting_evidence) + list(summary.top_opposing_evidence)
|
||||
|
||||
deterministic_thesis = build_thesis(summary, result)
|
||||
deterministic_thesis = build_thesis(summary, result, projection=projection)
|
||||
risk_class = classify_risk(summary, result)
|
||||
|
||||
# Use LLM-rewritten thesis if available, otherwise deterministic
|
||||
@@ -324,6 +413,13 @@ def build_recommendation(
|
||||
f"reasons={', '.join(reason_strs)})]"
|
||||
)
|
||||
|
||||
# Determine time_horizon — refine with projection horizon if available
|
||||
# (Requirement 12.8)
|
||||
time_horizon = result.time_horizon
|
||||
if projection is not None and not projection.low_confidence:
|
||||
# Append projection horizon context to time_horizon
|
||||
time_horizon = f"{result.time_horizon} (proj:{projection.projection_horizon})"
|
||||
|
||||
# Track whether the thesis was LLM-generated for audit
|
||||
if llm_thesis:
|
||||
provider = "ollama"
|
||||
@@ -339,7 +435,7 @@ def build_recommendation(
|
||||
action=result.action,
|
||||
mode=result.mode,
|
||||
confidence=summary.confidence,
|
||||
time_horizon=result.time_horizon,
|
||||
time_horizon=time_horizon,
|
||||
thesis=f"[risk:{risk_class}] {thesis_body}",
|
||||
invalidation_conditions=result.invalidation_conditions,
|
||||
position_sizing=PositionSizing(
|
||||
@@ -574,12 +670,13 @@ async def generate_recommendation(
|
||||
|
||||
Steps:
|
||||
1. Fetch the latest trend summary for the ticker + window.
|
||||
2. Evaluate data quality suppression (Requirement 7.4).
|
||||
3. Evaluate eligibility using deterministic rules.
|
||||
4. Build a Recommendation object with thesis and evidence.
|
||||
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.
|
||||
5. Build a Recommendation object with thesis and evidence.
|
||||
- If ``ollama_config`` is provided, the deterministic thesis is
|
||||
rewritten into analyst-quality prose via the LLM wording layer.
|
||||
5. Persist the recommendation and evidence citations.
|
||||
6. Persist the recommendation and evidence citations.
|
||||
|
||||
Returns the Recommendation, or None if no trend data exists.
|
||||
"""
|
||||
@@ -595,13 +692,23 @@ async def generate_recommendation(
|
||||
logger.info("No trend data for %s/%s — skipping recommendation", ticker, window)
|
||||
return None
|
||||
|
||||
# 2. Evaluate data quality suppression (Requirement 7.4)
|
||||
# 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
|
||||
# eligibility (Requirement 12.9). The projection is still passed to
|
||||
# build_recommendation for informational display, but marked as
|
||||
# low_confidence so it won't affect thesis or time_horizon.
|
||||
effective_projection = projection
|
||||
if projection is not None and projection.low_confidence:
|
||||
effective_projection = projection # still passed, but build_thesis checks low_confidence
|
||||
|
||||
# 3. Evaluate data quality suppression (Requirement 7.4)
|
||||
quality_ctx = await fetch_data_quality_context(pool, ticker, window)
|
||||
suppression = evaluate_suppression(
|
||||
summary, quality_ctx=quality_ctx, config=sup_cfg, reference_time=reference_time,
|
||||
)
|
||||
|
||||
# 3. Evaluate eligibility
|
||||
# 4. Evaluate eligibility
|
||||
result = evaluate_eligibility(summary, cfg)
|
||||
|
||||
# Apply suppression: force mode to informational if suppressed
|
||||
@@ -616,10 +723,10 @@ async def generate_recommendation(
|
||||
invalidation_conditions=result.invalidation_conditions,
|
||||
)
|
||||
|
||||
# 4. Optional LLM thesis rewrite
|
||||
# 5. Optional LLM thesis rewrite
|
||||
llm_thesis: str | None = None
|
||||
if ollama_config is not None:
|
||||
deterministic_thesis = build_thesis(summary, result)
|
||||
deterministic_thesis = build_thesis(summary, result, projection=effective_projection)
|
||||
llm_thesis = await rewrite_thesis_with_llm(
|
||||
deterministic_thesis=deterministic_thesis,
|
||||
summary=summary,
|
||||
@@ -630,13 +737,14 @@ async def generate_recommendation(
|
||||
if llm_thesis == deterministic_thesis:
|
||||
llm_thesis = None
|
||||
|
||||
# 5. Build recommendation
|
||||
# 6. Build recommendation
|
||||
rec = build_recommendation(
|
||||
summary, result, reference_time, llm_thesis=llm_thesis,
|
||||
suppression_result=suppression,
|
||||
projection=effective_projection,
|
||||
)
|
||||
|
||||
# 6. Persist recommendation, evidence citations, and risk evaluation
|
||||
# 7. Persist recommendation, evidence citations, and risk evaluation
|
||||
rec_id = await persist_recommendation(
|
||||
pool,
|
||||
rec,
|
||||
@@ -645,7 +753,7 @@ async def generate_recommendation(
|
||||
eligibility_result=result,
|
||||
)
|
||||
|
||||
# 7. Publish prediction facts to analytical tables (Requirement 9.4)
|
||||
# 8. Publish prediction facts to analytical tables (Requirement 9.4)
|
||||
if minio_client is not None:
|
||||
try:
|
||||
lake_refs = publish_recommendation_facts(
|
||||
@@ -667,10 +775,11 @@ async def generate_recommendation(
|
||||
|
||||
logger.info(
|
||||
"Generated recommendation %s for %s: action=%s mode=%s confidence=%.3f "
|
||||
"eligible=%s suppressed=%s quality_score=%.3f llm_thesis=%s",
|
||||
"eligible=%s suppressed=%s quality_score=%.3f llm_thesis=%s projection=%s",
|
||||
rec_id, ticker, rec.action.value, rec.mode.value, rec.confidence,
|
||||
result.eligible, suppression.suppressed, suppression.data_quality_score,
|
||||
llm_thesis is not None,
|
||||
projection.projected_direction if projection else "none",
|
||||
)
|
||||
|
||||
# Prometheus metrics
|
||||
|
||||
Reference in New Issue
Block a user