Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion config/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,17 @@ trading:
# Data Center REITs (AI infra exposure — interconnect + hyperscale)
- EQIX # Equinix (global #1 interconnection / colocation)
- DLR # Digital Realty (hyperscale data-center REIT)
lookback_days: 120
# CALENDAR days of OHLCV history — market.get_ohlcv does
# `start = today - timedelta(days=lookback_days)`, so the usable BAR count is
# only ~69% of this (120 gave ~82 bars).
#
# 2026-07-16 audit: MA200 needs 200 bars, so `ma_200` was unconditionally
# None in production and the tech_analyst prompt rendered "MA200=None" for
# every symbol every day — the analyst was asked to judge trend with the
# most-used long-term reference permanently absent. 320 calendar days ≈ 220
# trading days: enough for MA200 with holiday headroom. Pinned by
# tests/test_audit_fixes_2026_07_16.py against every advertised indicator.
lookback_days: 320
# NOTE: these times are only consumed by `main.py --mode live` (APScheduler).
# Production runs via launchd — see `src/trading_calendar.py SESSION_WINDOWS`
# for the authoritative ET windows and `scripts/run_if_et_window.sh` for the
Expand Down
12 changes: 12 additions & 0 deletions src/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,18 @@ class AgentResult:
@staticmethod
def _shape_score(parsed) -> int:
"""How 'agent-output shaped' a JSON candidate looks. Higher is better."""
# A top-level LIST is a first-class agent shape: tech_analyst returns
# an array of per-symbol analyses (tech_analyst.py: `items = parsed if
# isinstance(parsed, list) else [parsed]`). Scoring it 0 meant that
# whenever the model wrapped the array in ANY prose (so the clean
# json.loads happy path missed), the candidate scan compared the array
# (score 0) against each of its own elements (score > 0) and returned
# the LAST ELEMENT — silently discarding every other symbol's analysis
# in the chunk. Score the container by the SUM of its elements so it
# strictly outranks any single element it contains (2026-07-16 audit;
# reproduced: a 3-analysis array returned 1 dict).
if isinstance(parsed, list):
return sum(AgentResult._shape_score(item) for item in parsed)
if not isinstance(parsed, dict):
return 0
keys = set(parsed.keys())
Expand Down
10 changes: 10 additions & 0 deletions src/agents/position_reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ def _pnl_pct(p: Position) -> str:
metric_bits.append(f"to_stop={pf['distance_to_stop_pct']:.1f}%")
if pf.get("distance_to_target_pct") is not None:
metric_bits.append(f"to_target={pf['distance_to_target_pct']:.1f}%")
# Vol units. The prompt tells the reviewer to reason in ATRs ("a
# stop <1.25 ATRs away is inside daily noise") and the pipeline
# pays for an ATR fetch per position to compute these — but they
# were never rendered, so the instruction referred to data the
# model could not see (2026-07-16 audit; drift introduced with the
# exit-quality work). None => omit: unknown must not read as zero.
if pf.get("atr_pct") is not None:
metric_bits.append(f"atr={pf['atr_pct']:.2f}%")
if pf.get("stop_distance_atrs") is not None:
metric_bits.append(f"stop_distance={pf['stop_distance_atrs']:.2f}×ATR")
if pf.get("weight_pct") is not None:
metric_bits.append(f"weight={pf['weight_pct']:.1f}%")
if metric_bits:
Expand Down
28 changes: 27 additions & 1 deletion src/data/earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,12 +622,38 @@ def _check_symbol(self, symbol: str) -> EarningsReport | None:
)
return self._get_existing_analysis(symbol, form_type=latest.form_type)

if last_known == latest.filing_date:
# "Already processed" must mean SUCCEEDED, not merely attempted.
#
# 2026-07-16 audit: record_failure() writes the new filing_date into
# the manifest alongside failed_attempts=1 and logs "will retry next
# session" — but this gate then saw last_known == latest.filing_date,
# found the PRIOR quarter's analysis on disk, and returned it with
# is_new=False. The pipeline only re-queues is_new reports, so the
# filing was never re-analyzed, record_failure never ticked again, the
# 3-strike budget never reached `abandoned`, and PM was served last
# quarter's numbers labelled "[from cache]" as if they were current.
# One transient LLM failure permanently dropped that quarter's filing —
# for every symbol that already had a same-form analysis on disk, i.e.
# the whole universe in steady state.
# confirm_filing() writes failed_attempts=0 on success, so a genuinely
# processed filing still short-circuits here. Attempts 1-2 now fall
# through to re-download → is_new=True → re-analysis; on the 3rd
# failure the `abandoned` branch above takes over as designed.
try:
prior_failures = int(entry.get("failed_attempts", 0) or 0)
except (TypeError, ValueError):
prior_failures = 0
if last_known == latest.filing_date and not prior_failures:
# Already processed this filing — return existing analysis matching this form_type
existing = self._get_existing_analysis(symbol, form_type=latest.form_type)
if existing:
return existing
# Analysis file missing (e.g. killed mid-analysis) — re-download
elif last_known == latest.filing_date and prior_failures:
logger.info(
"%s %s (%s): retrying after %d failed analysis attempt(s)",
symbol, latest.form_type, latest.filing_date, prior_failures,
)

# New filing — download it
local_path = self._download_filing(cik, latest)
Expand Down
16 changes: 15 additions & 1 deletion src/data/macro.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,21 @@ def get_credit_spread(self) -> dict:
if series.empty:
return {"current_bps": None, "change_30d_bps": None, "staleness_days": None}
current = float(series.iloc[-1]) * 100 # FRED returns % — convert to bps
prior_30d = float(series.iloc[0]) * 100 if len(series) >= 2 else current
# Anchor the reference to a DATE, not to the head of the window.
#
# 2026-07-16 audit: `series.iloc[0]` is the OLDEST observation in a
# 60-CALENDAR-day fetch, so "change_30d_bps" was really a ~57-60 day
# change — about 2x the advertised window, and on a live check it even
# flipped the sign (code said -11.0 bps; the true 30-day change was
# +6.0 bps). BAMLH0A0HYM2 is business-daily; keep the 60d fetch as
# buffer for holidays/gaps, but take the last observation at or before
# T-30d. (The wide window was inherited verbatim from the earlier
# MONTHLY FEDFUNDS fetcher, where iloc[0] was harmless.)
prior_30d = current
if len(series) >= 2:
cutoff = series.index[-1] - pd.Timedelta(days=30)
prior = series[series.index <= cutoff]
prior_30d = float(prior.iloc[-1] if not prior.empty else series.iloc[0]) * 100
return {
"current_bps": round(current, 1),
"change_30d_bps": round(current - prior_30d, 1),
Expand Down
49 changes: 49 additions & 0 deletions src/data/macro_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,42 @@ def _atomic_write(path: Path, data: str) -> None:
os.replace(str(tmp), str(path))


# MacroAnalysis.sector_guidance is a LIST of {sector, stance, reason} where
# stance ∈ overweight|neutral|underweight. Every consumer of the persisted
# state (`_missed_ops_macro_sector_map`, thesis-health) wants a DICT keyed by
# sector with bullish|neutral|bearish values. Convert once, on write.
_STANCE_TO_DIRECTION = {
"overweight": "bullish",
"neutral": "neutral",
"underweight": "bearish",
}


def _normalize_sector_guidance(raw) -> dict[str, str]:
"""[{sector, stance, reason}, ...] → {sector: bullish|neutral|bearish}.

Tolerates the already-normalized dict shape (idempotent) and drops
anything unrecognized. Never raises — a malformed guidance block must
not take down the macro save.
"""
out: dict[str, str] = {}
if isinstance(raw, dict):
for sector, direction in raw.items():
if isinstance(direction, str) and direction in ("bullish", "neutral", "bearish"):
out[str(sector)] = direction
return out
if not isinstance(raw, list):
return out
for item in raw:
if not isinstance(item, dict):
continue
sector = item.get("sector")
direction = _STANCE_TO_DIRECTION.get(str(item.get("stance") or "").lower())
if sector and direction:
out[str(sector)] = direction
return out


class MacroStore:
def __init__(self, data_dir: str = "data/macro"):
self.data_dir = Path(data_dir)
Expand Down Expand Up @@ -47,6 +83,19 @@ def save_last_state(self, analysis: dict) -> None:
"equity_outlook": analysis.get("equity_outlook"),
"summary": analysis.get("summary"),
"position_guidance": analysis.get("position_guidance"),
# 2026-07-16 audit: this key was never persisted, so EVERY
# downstream macro_sector_stance / macro_sector_tailwind was
# permanently "unknown" — the evening thesis-health step and every
# missed-opportunity snapshot rendered "Macro sector stance:
# unknown" for every position, every night, while macro was in fact
# emitting OW/UW calls. Stored pre-normalized (see
# _normalize_sector_guidance): the readers want {sector: direction}
# and the model carries a list of {sector, stance, reason}. The
# bulky `reason` strings stay out — this file's contract is "keep
# it small"; reasons live in agent_logs.
"sector_guidance": _normalize_sector_guidance(
analysis.get("sector_guidance")
),
}
_atomic_write(self.last_state_path, json.dumps(snapshot, indent=2, ensure_ascii=False))
logger.info("Saved macro last state → %s (regime=%s)",
Expand Down
Loading
Loading