Spaces:
Running
Running
File size: 24,555 Bytes
abab542 98a257d abab542 b490925 abab542 e61a2dc abab542 17b15cc abab542 bd55a7d abab542 17b15cc bd55a7d e61a2dc bd55a7d e61a2dc 4095ae9 e61a2dc abab542 e61a2dc abab542 e61a2dc abab542 e61a2dc 4095ae9 e61a2dc bd55a7d d62ac62 e61a2dc d62ac62 e61a2dc 4095ae9 bd55a7d 4095ae9 e61a2dc 4095ae9 17b15cc bd55a7d d62ac62 4095ae9 abab542 4095ae9 abab542 b490925 abab542 e61a2dc abab542 17b15cc bd55a7d 17b15cc bd55a7d 17b15cc b490925 e61a2dc 17b15cc e61a2dc 17b15cc e61a2dc bd55a7d abab542 e61a2dc abab542 17b15cc abab542 4095ae9 abab542 d62ac62 abab542 bd55a7d 17b15cc bd55a7d e61a2dc 17b15cc bd55a7d e61a2dc bd55a7d 17b15cc bd55a7d e61a2dc bd55a7d abab542 17b15cc bd55a7d 17b15cc abab542 98a257d abab542 98a257d abab542 98a257d e61a2dc abab542 e61a2dc abab542 e61a2dc abab542 b490925 e61a2dc b490925 abab542 b490925 abab542 b490925 abab542 e61a2dc abab542 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 | #!/usr/bin/env python3
"""
self_learning.py β Analyze prediction validation history and generate calibration notes
that get injected into the AI synthesis prompt so the model learns from its own failures.
Public API:
analyze_and_update(days=30) -> dict # compute stats, write learnings.json, return summary
get_learning_context() -> str # formatted block for synthesis prompt injection
"""
import json
import os
import logging
import re
from typing import Optional
def _data_dir() -> str:
"""Return persistent data directory. Uses /data on HF Spaces, else project root."""
hf_data = "/data"
if os.path.isdir(hf_data) and os.access(hf_data, os.W_OK):
return hf_data
return os.path.dirname(os.path.abspath(__file__))
_MIN_SAMPLES = 10 # require at least this many validated rows before injecting learnings
_MAX_STORED_RECORDS = 5000 # slim records kept in learnings.json (preserves history post-prune)
# The LLM-driven calibration analysis ("Improve AI") is expensive and its conclusions barely
# move day-to-day, so it is throttled: the LLM notes refresh at most once every N days. The
# cheap arithmetic target-reach notes + hit/miss buckets STILL refresh on every validation, so
# learnings.json (the file fed into the AI synthesis prompt) stays current. The manual
# "π§ Improve AI" button forces an immediate LLM refresh (force_llm=True). Set the interval to
# 0 to run the LLM every time (old behavior).
_LEARN_LLM_INTERVAL_DAYS = int(os.getenv("SELF_LEARN_LLM_INTERVAL_DAYS", "7"))
def _days_since(date_str: Optional[str]) -> Optional[int]:
"""Whole days between an IST 'YYYY-MM-DD' stamp and today (IST). None if unparseable."""
if not date_str:
return None
try:
from datetime import datetime
d = datetime.strptime(str(date_str)[:10], "%Y-%m-%d").date()
today = datetime.strptime(_today_ist(), "%Y-%m-%d").date()
return (today - d).days
except Exception:
return None
def _slim_record(r: dict) -> dict:
"""Slimmed validated record kept in learnings.json for the history tab (survives DB
pruning). Includes snapshot_source so learnings can be split into AI vs ML."""
return {
"id": r.get("id"),
"ticker": r.get("ticker"),
"timeframe": r.get("timeframe"),
"direction": r.get("direction"),
"confidence": r.get("confidence"),
"target_price_lo": r.get("target_price_lo"),
"target_price_hi": r.get("target_price_hi"),
"predicted_return_lo": r.get("predicted_return_lo"),
"predicted_return_hi": r.get("predicted_return_hi"),
"current_price": r.get("current_price"),
"actual_price_at_validation": r.get("actual_price_at_validation"),
"actual_return_at_validation": r.get("actual_return_at_validation"),
"window_high": r.get("window_high"),
"window_low": r.get("window_low"),
"validation_result": r.get("validation_result"),
"snapshot_source": r.get("snapshot_source"),
"created_at": r.get("created_at"),
"validated_at": r.get("validated_at"),
"validation_target_date": r.get("validation_target_date"),
}
def _analyze_bucket_set(new_validated: list, existing: dict, days: int, use_llm: bool = True,
llm_allowed: bool = True) -> dict:
"""Compute calibration stats (buckets, confidence, notes, records) for ONE prediction
source (AI or ML).
Aggregates are rebuilt from a DEDUPLICATED union of (a) the current DB validated rows
and (b) previously-stored slim records that are no longer in the DB (pruned history).
Dedup is by snapshot id, so a row present in BOTH the DB and the stored history is counted
exactly once β the analysis is idempotent and safe to re-run without pruning in between
(the old approach seeded historical totals AND re-counted un-pruned DB rows, double-counting).
"""
existing = existing or {}
existing_records: list = existing.get("records", [])
# Union of records: current DB rows first, then stored records whose id isn't in the DB set.
new_ids: set = {r.get("id") for r in new_validated if r.get("id") is not None}
combined_src: list = list(new_validated)
for er in existing_records:
if er.get("id") not in new_ids:
combined_src.append(er)
merged_buckets: dict = {}
merged_conf: dict = {}
total = 0
total_hits = 0
all_records: list = []
for r in combined_src:
result = r.get("validation_result")
if result not in ("HIT", "MISS"):
continue
direction = (r.get("direction") or "").upper()
if direction in ("N/A", "NO TRADE", "NEUTRAL", ""):
continue
timeframe = (r.get("timeframe") or "1D").upper()
confidence = (r.get("confidence") or "LOW").upper()
hit = result == "HIT"
key = f"{direction}_{timeframe}"
b = merged_buckets.setdefault(
key, {"hits": 0, "total": 0, "direction": direction, "timeframe": timeframe}
)
b["hits"] += int(hit)
b["total"] += 1
c = merged_conf.setdefault(confidence, {"hits": 0, "total": 0})
c["hits"] += int(hit)
c["total"] += 1
total += 1
total_hits += int(hit)
all_records.append(_slim_record(r))
all_records.sort(key=lambda x: x.get("validated_at") or "", reverse=True)
all_records = all_records[:_MAX_STORED_RECORDS]
new_in_this_run = sum(
1 for r in new_validated
if r.get("id") is not None and r.get("id") not in {er.get("id") for er in existing_records}
)
if total < _MIN_SAMPLES:
return {
"status": "insufficient_data",
"total_validated": total,
"new_in_this_run": new_in_this_run,
"min_required": _MIN_SAMPLES,
"calibration_notes": [],
"notes_base": (existing or {}).get("notes_base", []),
"notes_updated_at": (existing or {}).get("notes_updated_at"),
"buckets": {},
"confidence_stats": {},
"records": all_records,
}
bucket_stats = {}
for key, b in sorted(merged_buckets.items(), key=lambda x: x[1]["total"], reverse=True):
if b["total"] < 5:
continue
hit_rate = b["hits"] / b["total"]
bucket_stats[key] = {
"hits": b["hits"],
"total": b["total"],
"hit_rate": round(hit_rate, 3),
"miss_rate": round(1 - hit_rate, 3),
}
overall_accuracy = round(total_hits / total, 3) if total else 0
prev_notes_base = (existing or {}).get("notes_base")
prev_notes_at = (existing or {}).get("notes_updated_at")
notes_updated_at = _today_ist()
if use_llm:
if llm_allowed or not prev_notes_base:
try:
notes_base = _llm_calibration_notes(bucket_stats, merged_conf, all_records, overall_accuracy)
except Exception as _le:
logging.warning("LLM calibration failed (%s) β using arithmetic fallback", _le)
notes_base = _arithmetic_calibration_notes(bucket_stats, merged_conf)
else:
# Throttled: reuse the last LLM notes (their conclusions barely move day-to-day).
# The cheap target-reach notes below still refresh, so learnings.json stays actionable.
notes_base = list(prev_notes_base)
notes_updated_at = prev_notes_at or notes_updated_at
else:
# ML is a trained model (no prompt to inject into), so its notes stay descriptive /
# arithmetic β same WARN/CAUTION/OK hit-miss blocks, no LLM cost.
notes_base = _arithmetic_calibration_notes(bucket_stats, merged_conf)
# Prepend target-reach diagnostics (predicted high/low/mid vs the stock's ACTUAL high/low)
# so the panel explains WHY a target was missed β e.g. a high target price never touched.
# These are arithmetic + cheap, so they ALWAYS refresh (even on LLM-throttled runs), keeping
# the "pull the target in" guidance current in the JSON fed to the AI.
target_notes = _target_reach_notes(all_records)
calibration_notes = target_notes + notes_base
return {
"total_validated": total,
"new_in_this_run": new_in_this_run,
"overall_accuracy": overall_accuracy,
"buckets": bucket_stats,
"calibration_notes": calibration_notes,
"notes_base": notes_base,
"notes_updated_at": notes_updated_at,
"confidence_stats": {
k: {"hits": v["hits"], "total": v["total"], "hit_rate": round(v["hits"] / v["total"], 3)}
for k, v in merged_conf.items() if v["total"] >= 5
},
"records": all_records,
}
def analyze_and_update(days: Optional[int] = None, force_llm: bool = False) -> dict:
"""
Query prediction_snapshots for validated results and write learnings.json.
`days=None` (the default) analyzes the ENTIRE validated history β every prediction
matters, so there is no rolling window. Pass a positive int only to restrict the window.
`force_llm=True` bypasses the LLM-refresh throttle and re-runs the (expensive) LLM
calibration immediately β used by the manual "π§ Improve AI" button. Automatic callers
(post-validation + scheduler) leave it False, so the LLM notes refresh at most once every
`_LEARN_LLM_INTERVAL_DAYS` days while the cheap target/bucket stats still update each run.
AI and ML predictions are analyzed SEPARATELY (by snapshot_source) so each gets its own
hit/miss buckets, confidence stats, and calibration notes β the panel shows both. The
top-level fields mirror the AI block for backward compatibility: get_learning_context()
injects AI-only learnings into the AI synthesis prompt (ML data no longer pollutes it).
"""
import database as db
# Pull the FULL history (days=None β no time window). get_prediction_snapshots caps at
# `limit`, so request a high cap to capture every validated row.
rows = db.get_prediction_snapshots(days=days, limit=100000)
validated = [
r for r in rows
if r.get("validation_status") == "VALIDATED"
and r.get("validation_result") in ("HIT", "MISS")
and r.get("direction", "").upper() not in ("N/A", "NO TRADE", "NEUTRAL", "")
]
ai_new = [r for r in validated if (r.get("snapshot_source") or "").lower() != "ml"]
ml_new = [r for r in validated if (r.get("snapshot_source") or "").lower() == "ml"]
existing = _read() or {}
# New format stores per-source blocks under "ai"/"ml". Old (flat) format is treated as the
# AI seed β records carry snapshot_source, so subsequent runs self-correct the split.
ai_existing = existing.get("ai") if "ai" in existing else existing
ml_existing = existing.get("ml", {})
# Throttle gate: allow the LLM to run when forced, when the interval is disabled (0), when
# no prior LLM notes exist, or when enough days have elapsed since the last LLM refresh.
prev_llm_at = (ai_existing or {}).get("notes_updated_at")
_elapsed = _days_since(prev_llm_at)
llm_allowed = bool(
force_llm
or _LEARN_LLM_INTERVAL_DAYS <= 0
or prev_llm_at is None
or _elapsed is None
or _elapsed >= _LEARN_LLM_INTERVAL_DAYS
)
ai_block = _analyze_bucket_set(ai_new, ai_existing or {}, days, use_llm=True, llm_allowed=llm_allowed)
ml_block = _analyze_bucket_set(ml_new, ml_existing or {}, days, use_llm=False)
# Combined records (both sources) for the validation history tab β dedup by id + cap.
seen: set = set()
combined: list = []
for rec in (ai_block.get("records", []) + ml_block.get("records", [])):
rid = rec.get("id")
if rid is not None and rid in seen:
continue
seen.add(rid)
combined.append(rec)
combined.sort(key=lambda x: x.get("validated_at") or "", reverse=True)
combined = combined[:_MAX_STORED_RECORDS]
result = {
"updated_at": _today_ist(),
"days": days,
**ai_block, # top-level == AI block (backward compat)
"records": combined, # ...but history tab needs both sources
"ai": ai_block,
"ml": ml_block,
}
_write(result)
logging.info(
"Self-learning updated: AI N=%d (%.0f%%, +%d), ML N=%d (%.0f%%, +%d) [LLM %s]",
ai_block.get("total_validated", 0), ai_block.get("overall_accuracy", 0) * 100, len(ai_new),
ml_block.get("total_validated", 0), ml_block.get("overall_accuracy", 0) * 100, len(ml_new),
"refreshed" if llm_allowed else f"reused (next in {_LEARN_LLM_INTERVAL_DAYS - (_elapsed or 0)}d)",
)
return result
_TF_TOKENS = ("INTRADAY", "1D", "3D", "5D")
def _note_matches_tf(note: str, tf_label: str) -> bool:
"""True if `note` has no TF token (a TF-agnostic note, e.g. confidence calibration) or
its TF token equals `tf_label` β prevents a 3D calibration note from being applied to an
INTRADAY (or any other mismatched timeframe) synthesis call."""
upper = note.upper()
found = [t for t in _TF_TOKENS if re.search(rf"\b{t}\b", upper)]
return (not found) or (tf_label in found)
def get_learning_context(tf_label: Optional[str] = None) -> str:
"""
Return a formatted block for injection into the synthesis prompt.
When `tf_label` is given, notes are filtered to that timeframe (plus TF-agnostic notes,
e.g. confidence-level calibration) so a 3D lesson is never applied to an INTRADAY call β
and the header N/accuracy are recomputed from that timeframe's own buckets when available.
Returns empty string if learnings.json is missing, stale, or has too little data.
"""
data = _read()
if not data:
return ""
total = data.get("total_validated", 0)
if total < _MIN_SAMPLES:
return ""
notes = data.get("calibration_notes", [])
if not notes:
return ""
accuracy = data.get("overall_accuracy", 0)
if tf_label:
tf_u = tf_label.upper()
notes = [n for n in notes if _note_matches_tf(n, tf_u)]
if not notes:
return ""
buckets = data.get("buckets", {}) or {}
tf_buckets = {k: v for k, v in buckets.items() if k.endswith(f"_{tf_u}")}
tf_total = sum(v["total"] for v in tf_buckets.values())
tf_hits = sum(v["hits"] for v in tf_buckets.values())
if tf_total > 0:
total = tf_total
accuracy = tf_hits / tf_total
days = data.get("days")
updated = data.get("updated_at", "")
window = f"last {days} days" if days else "all history"
lines = [
f"LEARNING FROM RECENT PREDICTIONS ({window}, N={total}, accuracy={accuracy:.0%}, updated {updated}):"
]
for note in notes:
lines.append(f"- {note}")
return "\n".join(lines)
# ββ Internal helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _learnings_path() -> str:
return os.path.join(_data_dir(), "learnings.json")
def _arithmetic_calibration_notes(bucket_stats: dict, merged_conf: dict) -> list:
"""Fallback: threshold-based calibration notes (no LLM required)."""
notes = []
for key, s in bucket_stats.items():
parts = key.split("_", 1)
direction = parts[0]
timeframe = parts[1] if len(parts) > 1 else "1D"
n = s["total"]
miss_rate = s["miss_rate"]
hit_rate = s["hit_rate"]
if miss_rate > 0.25:
notes.append(
f"WARN {direction} {timeframe}: {miss_rate:.0%} miss rate (N={n}) β "
f"only call {direction} {timeframe} when a strong trigger fires"
)
elif miss_rate > 0.15:
notes.append(
f"CAUTION {direction} {timeframe}: {miss_rate:.0%} miss rate (N={n}) β "
f"require at least 2 confirming signals before calling {direction}"
)
elif hit_rate >= 0.90:
notes.append(
f"OK {direction} {timeframe}: {hit_rate:.0%} hit rate (N={n}) β "
f"triggers are well-calibrated, trust them"
)
for conf in ("HIGH", "MEDIUM", "LOW"):
cc = merged_conf.get(conf, {})
if cc.get("total", 0) < 5:
continue
hr = cc["hits"] / cc["total"]
if conf == "HIGH" and hr < 0.85:
notes.append(
f"WARN HIGH confidence calls are only {hr:.0%} accurate (N={cc['total']}) β "
f"be stricter before assigning HIGH confidence"
)
return notes
def _target_reach_notes(records: list) -> list:
"""Compare the predicted target band (high/low/mid) against the stock's ACTUAL price
window (window_high/window_low) to explain WHY a prediction missed β e.g. a high target
the price never reached. Groups by direction+timeframe and reports the reach rate plus
the average shortfall, so the panel says exactly how far off the target was. Works for
both AI and ML records (both carry window_high/low + target band)."""
from collections import defaultdict
groups: dict = defaultdict(list)
for r in records:
direction = (r.get("direction") or "").upper()
tf = (r.get("timeframe") or "1D").upper()
if direction not in ("BULLISH", "BEARISH", "SLIGHTLY BULLISH", "SLIGHTLY BEARISH"):
continue
entry = r.get("current_price") or 0
wh = r.get("window_high")
wl = r.get("window_low")
t_lo = r.get("target_price_lo")
t_hi = r.get("target_price_hi")
if not entry or wh is None or wl is None or t_lo is None or t_hi is None:
continue
groups[(direction, tf)].append((entry, wh, wl, t_lo, t_hi))
notes = []
for (direction, tf), rows in sorted(groups.items(), key=lambda x: -len(x[1])):
n = len(rows)
if n < 5:
continue
bullish = "BULLISH" in direction
reached = 0
shortfalls = [] # how far short of the target the price stopped (% of entry)
pred_target_pcts = [] # predicted best-case target as % move
actual_extreme_pcts = [] # actual best-case reached as % move
mid_reached = 0 # did price at least touch the midpoint?
for entry, wh, wl, t_lo, t_hi in rows:
mid = (t_lo + t_hi) / 2.0
if bullish:
pred_target_pcts.append((t_hi - entry) / entry * 100)
actual_extreme_pcts.append((wh - entry) / entry * 100)
if wh >= t_hi:
reached += 1
else:
shortfalls.append((t_hi - wh) / entry * 100)
if wh >= mid:
mid_reached += 1
else:
pred_target_pcts.append((t_lo - entry) / entry * 100)
actual_extreme_pcts.append((wl - entry) / entry * 100)
if wl <= t_lo:
reached += 1
else:
shortfalls.append((wl - t_lo) / entry * 100)
if wl <= mid:
mid_reached += 1
reach_rate = reached / n
mid_rate = mid_reached / n
avg_pred = sum(pred_target_pcts) / n
avg_actual = sum(actual_extreme_pcts) / n
edge = "high" if bullish else "low"
if reach_rate < 0.5 and shortfalls:
avg_short = sum(shortfalls) / len(shortfalls)
notes.append(
f"MISS {direction} {tf}: {edge} target hit only {reach_rate:.0%} (N={n}) β "
f"predicted {edge} avg {avg_pred:+.1f}% but price only reached {avg_actual:+.1f}% "
f"(~{avg_short:.1f}% too far); midpoint touched {mid_rate:.0%} β pull the {edge} target in"
)
elif reach_rate >= 0.85:
notes.append(
f"OK {direction} {tf}: {edge} target hit {reach_rate:.0%} (N={n}) β "
f"band well-placed (pred {avg_pred:+.1f}% vs reached {avg_actual:+.1f}%)"
)
elif mid_rate < 0.6:
notes.append(
f"CAUTION {direction} {tf}: midpoint hit only {mid_rate:.0%} (N={n}) β "
f"predicted {edge} avg {avg_pred:+.1f}% vs actual {avg_actual:+.1f}%; narrow the band"
)
return notes
def _llm_calibration_notes(
bucket_stats: dict,
merged_conf: dict,
records: list,
overall_accuracy: float,
) -> list:
"""LLM-driven calibration: reasons over hit/miss patterns and near-misses."""
from llm_client import make_chat_call
# Build per-bucket hit-rate summary
bucket_lines = []
for key, s in bucket_stats.items():
bucket_lines.append(
f" {key}: {s['hit_rate']:.0%} hit rate ({s['hits']}/{s['total']})"
)
# Recent 30 records for near-miss analysis
recent = []
for r in records[:30]:
recent.append({
"ticker": r.get("ticker"),
"tf": r.get("timeframe"),
"dir": r.get("direction"),
"conf": r.get("confidence"),
"result": r.get("validation_result"),
"pred_lo": r.get("predicted_return_lo"),
"pred_hi": r.get("predicted_return_hi"),
"actual": r.get("actual_return_at_validation"),
"win_hi": r.get("window_high"),
"win_lo": r.get("window_low"),
})
conf_lines = []
for conf in ("HIGH", "MEDIUM", "LOW"):
cc = merged_conf.get(conf, {})
if cc.get("total", 0) >= 5:
hr = cc["hits"] / cc["total"]
conf_lines.append(f" {conf}: {hr:.0%} ({cc['hits']}/{cc['total']})")
prompt = f"""You are a trading system calibration analyst. The system targets β₯85% hit rate.
Overall accuracy: {overall_accuracy:.0%}
Direction+timeframe hit rates (target β₯85%):
{chr(10).join(bucket_lines) if bucket_lines else " (no buckets with Nβ₯5 yet)"}
Confidence hit rates:
{chr(10).join(conf_lines) if conf_lines else " (insufficient data)"}
Recent predictions (last 30):
{json.dumps(recent, indent=None)}
Tasks:
1. Flag any direction+timeframe bucket below 85% hit rate with WARN (below 75%) or CAUTION (75-84%).
2. Identify near-misses: records where the actual return was within 20% of the predicted range boundary.
3. Cross-tab: if HIGH-confidence calls underperform MEDIUM, flag it.
4. If the 10 most recent records trend worse than overall, add a RECENT_DRIFT warning.
5. For buckets at β₯90% hit rate, add an OK note to reinforce the pattern.
Respond ONLY with a JSON array of strings. Each string starts with WARN, CAUTION, OK, or RECENT_DRIFT.
Example: ["WARN BULLISH 1D: 30% miss rate (N=20) β tighten trigger requirements", "OK BEARISH 3D: 92% hit rate (N=15) β well calibrated"]
No markdown, no explanation, just the JSON array."""
content, _, _ = make_chat_call(
messages=[{"role": "user", "content": prompt}],
max_tokens=400,
temperature=0.2,
fast_fail_on_rate_limit=True,
max_retries=2,
)
raw = content.strip()
# Strip markdown fences if present
import re
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.MULTILINE).strip()
notes = json.loads(raw)
if not isinstance(notes, list):
raise ValueError(f"Expected list, got {type(notes)}")
return [str(n) for n in notes if isinstance(n, str) and n.strip()]
def _write(data: dict) -> None:
try:
with open(_learnings_path(), "w") as f:
json.dump(data, f, indent=2)
except Exception as e:
logging.warning("Could not write learnings.json: %s", e)
def _read() -> Optional[dict]:
try:
with open(_learnings_path()) as f:
return json.load(f)
except FileNotFoundError:
return None
except Exception as e:
logging.warning("Could not read learnings.json: %s", e)
return None
def _today_ist() -> str:
from datetime import datetime, timedelta, timezone
return datetime.now(timezone.utc).astimezone(
timezone(timedelta(hours=5, minutes=30))
).strftime("%Y-%m-%d")
if __name__ == "__main__":
import sys
days = int(sys.argv[1]) if len(sys.argv) > 1 else None
result = analyze_and_update(days=days)
print(json.dumps(result, indent=2))
print("\n--- Prompt context ---")
print(get_learning_context() or "(no context yet)")
|