feat: competitive intelligence & historical pattern matching layer

This commit is contained in:
Celes Renata
2026-04-14 19:42:48 +00:00
parent b478022ba3
commit f7a11d14ea
203 changed files with 20155 additions and 97 deletions
+115
View File
@@ -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
+123 -14
View File
@@ -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