202 lines
8.4 KiB
Python
202 lines
8.4 KiB
Python
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
|
import time
|
|
import json
|
|
import pandas as pd
|
|
from io import StringIO
|
|
from datetime import datetime, timedelta
|
|
|
|
from tradingagents.agents.utils.agent_utils import normalize_agent_output
|
|
from tradingagents.engines.regime_detector import RegimeDetector, DynamicIndicatorSelector
|
|
from tradingagents.utils.anonymizer import TickerAnonymizer
|
|
from tradingagents.utils.logger import app_logger as logger
|
|
from tradingagents.dataflows.config import get_config
|
|
|
|
def _calculate_net_insider_flow(raw_data: str) -> float:
|
|
"""Calculate net insider transaction value from report string."""
|
|
try:
|
|
if not raw_data or "Error" in raw_data or "No insider" in raw_data:
|
|
return 0.0
|
|
|
|
# Robust CSV parsing - YFinance uses whitespace delimiter
|
|
try:
|
|
df = pd.read_csv(StringIO(raw_data), sep='\s+', comment='#')
|
|
except:
|
|
# Fallback: auto-detect separator
|
|
df = pd.read_csv(StringIO(raw_data), sep=None, engine='python', comment='#')
|
|
|
|
# Standardize columns
|
|
df.columns = [c.strip().lower() for c in df.columns]
|
|
|
|
if 'value' not in df.columns:
|
|
return 0.0
|
|
|
|
net_flow = 0.0
|
|
|
|
# Iterate and sum
|
|
for _, row in df.iterrows():
|
|
# Check for sale/purchase in text or other columns
|
|
text = str(row.get('text', '')).lower() + str(row.get('transaction', '')).lower()
|
|
val = float(row['value']) if pd.notnull(row['value']) else 0.0
|
|
|
|
if 'sale' in text or 'sold' in text:
|
|
net_flow -= val
|
|
elif 'purchase' in text or 'buy' in text or 'bought' in text:
|
|
net_flow += val
|
|
|
|
return net_flow
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse insider flow: {e}")
|
|
return 0.0
|
|
|
|
def create_market_analyst(llm):
|
|
# PARANOIA CHECK: Ensure we aren't passing a bind_tools wrapped LLM
|
|
if hasattr(llm, "tools") and llm.tools:
|
|
logger.critical("SECURITY VIOLATION: Market Analyst has access to tools! This violates Phase 1 Architecture.")
|
|
|
|
def market_analyst_node(state):
|
|
logger.info(f">>> STARTING MARKET ANALYST for {state.get('company_of_interest')} <<<")
|
|
current_date = state["trade_date"]
|
|
|
|
# 1. READ FROM LEDGER (No Tool Calls)
|
|
ledger = state.get("fact_ledger")
|
|
if not ledger:
|
|
raise RuntimeError("CRITICAL: Market Analyst woke up but FactLedger is missing! Registrar failed.")
|
|
|
|
# Extract Canonically Fetched Data
|
|
raw_price_data = ledger.get("price_data")
|
|
raw_insider_data = ledger.get("insider_data")
|
|
|
|
# Initialize default state
|
|
report = "Market Analysis Initialized..."
|
|
regime_val = "UNKNOWN (Start)"
|
|
metrics = {"volatility": 0.0}
|
|
broad_market_regime = "UNKNOWN (Initialized)"
|
|
net_insider_flow = 0.0
|
|
volatility_score = 0.0
|
|
result = None
|
|
|
|
try:
|
|
# Re-initialize or reload anonymizer state
|
|
anonymizer = TickerAnonymizer()
|
|
real_ticker = state["company_of_interest"]
|
|
ticker = anonymizer.anonymize_ticker(real_ticker)
|
|
|
|
optimal_params = {}
|
|
regime_context = "REGIME DETECTION FAILED or DATA UNAVAILABLE"
|
|
|
|
# --- PROCESS LEDGER DATA ---
|
|
try:
|
|
# RegimeDetector now handles all input types (DataFrame, Series, CSV String)
|
|
# Just pass the raw data directly - no need to parse here
|
|
if raw_price_data:
|
|
regime, metrics = RegimeDetector.detect_regime(raw_price_data)
|
|
regime_val = regime.value if hasattr(regime, "value") else str(regime)
|
|
|
|
# Dynamic Tuning
|
|
overrides = {}
|
|
try:
|
|
config_path = get_config().get("runtime_config_relative_path", "data_cache/runtime_config.json")
|
|
import os
|
|
if os.path.exists(config_path):
|
|
with open(config_path, 'r') as f:
|
|
overrides = json.load(f)
|
|
except:
|
|
pass
|
|
|
|
optimal_params = DynamicIndicatorSelector.get_optimal_parameters(regime, overrides)
|
|
volatility_score = metrics.get("volatility", 0.0)
|
|
|
|
logger.info(f"SUCCESS: Detected Regime: {regime_val}")
|
|
|
|
# Construct Context
|
|
regime_context = f"MARKET REGIME DETECTED: {regime_val}\n"
|
|
# Escape Braces for LangChain
|
|
metrics_str = json.dumps(metrics).replace("{", "{{").replace("}", "}}")
|
|
regime_context += f"METRICS: {metrics_str}\n"
|
|
regime_context += f"RECOMMENDED STRATEGY: {optimal_params.get('strategy', 'N/A')}\n"
|
|
else:
|
|
regime_val = "UNKNOWN (Ledger Data Empty/Error)"
|
|
except Exception as e:
|
|
logger.warning(f"Regime detection failed from Ledger: {e}")
|
|
# DEBUG: Print raw data on failure
|
|
if isinstance(raw_price_data, str):
|
|
print(f"DEBUG: Parsing Failed. Raw Data Start: {raw_price_data[:250]}...")
|
|
regime_val = f"UNKNOWN (Error: {str(e)})"
|
|
|
|
# --- PROCESS INSIDER DATA ---
|
|
try:
|
|
# We trust the ledger's insider data
|
|
if isinstance(raw_insider_data, str):
|
|
net_insider_flow = _calculate_net_insider_flow(raw_insider_data)
|
|
logger.info(f"Insider Net Flow calculated from Ledger: ${net_insider_flow:,.2f}")
|
|
except Exception as e_ins:
|
|
net_insider_flow = 0.0
|
|
|
|
# --- LLM CALL (NO TOOLS) ---
|
|
system_message = (
|
|
f"""ROLE: Quantitative Technical Analyst.
|
|
CONTEXT: You are analyzing an ANONYMIZED ASSET (ASSET_XXX).
|
|
DATA SOURCE: TRUSTED FACT LEDGER ID {ledger.get('ledger_id', 'UNKNOWN')}.
|
|
|
|
DYNAMIC MARKET REGIME CONTEXT:
|
|
{regime_context}
|
|
|
|
TASK: Write a technical analysis report based on the PROVIDED DATA.
|
|
DO NOT ATTEMPT TO CALL TOOLS. YOU HAVE NO TOOLS.
|
|
Analyze the trends, volatility, and insider flow based on the metrics provided above.
|
|
|
|
INDICATOR GUIDANCE:
|
|
Use the regime metrics (volatility, slope, adx) to infer the technical state.
|
|
|
|
STRICT COMPLIANCE:
|
|
1. DO NOT HALLUCINATE DATA not present in the context.
|
|
2. Cite "FactLedger" as your source.
|
|
3. If data is missing, state "Insufficient Data".
|
|
|
|
Make sure to append a Markdown table at the end of the report."""
|
|
)
|
|
|
|
prompt = ChatPromptTemplate.from_messages(
|
|
[
|
|
("system", system_message),
|
|
MessagesPlaceholder(variable_name="messages"),
|
|
]
|
|
)
|
|
|
|
# NOTE: NO BIND TOOLS
|
|
chain = prompt | llm
|
|
# Fix: Must pass dict to Chain when using MessagesPlaceholder
|
|
result = chain.invoke({"messages": state["messages"]})
|
|
report = result.content
|
|
|
|
except Exception as e_fatal:
|
|
logger.critical(f"CRITICAL ERROR in Market Analyst Node: {e_fatal}")
|
|
if "UNKNOWN" in str(regime_val):
|
|
regime_val = f"UNKNOWN (Fatal Crash: {str(e_fatal)})"
|
|
report = f"Market Analyst Node crashed: {e_fatal}"
|
|
risk_multiplier = 0.5
|
|
|
|
# --- ALPHA CALCULATOR ---
|
|
if "risk_multiplier" not in locals(): risk_multiplier = 1.0
|
|
|
|
# Simple Regime Logic (since we lost live broad market for now)
|
|
if "TRENDING_UP" in str(regime_val).upper():
|
|
risk_multiplier = 1.2
|
|
elif "TRENDING_DOWN" in str(regime_val).upper():
|
|
risk_multiplier = 0.0
|
|
elif "VOLATILE" in str(regime_val).upper():
|
|
risk_multiplier = 0.5
|
|
|
|
return {
|
|
"messages": [result] if result else [],
|
|
"market_report": normalize_agent_output(report),
|
|
"market_regime": regime_val,
|
|
"regime_metrics": metrics,
|
|
"volatility_score": volatility_score,
|
|
"broad_market_regime": broad_market_regime,
|
|
"net_insider_flow": net_insider_flow,
|
|
"risk_multiplier": risk_multiplier
|
|
}
|
|
|
|
return market_analyst_node
|