feat: vertical 3-column timeline + MA regime bootstrap engine
- TimelineVertical: replace horizontal frise with Y=time vertical layout, 3 columns (Long/Medium/Short), auto-scroll to selected date, sub-columns for overlapping events, today/selected-date horizontal lines - ma_analyzer.py: detect MA50/MA200 crossovers + MA100 slope changes + MA20 direction swings on EUR/USD, Brent, Gold, S&P500, US10Y (5y history) with 5-bar confirmation, dedup, GPT-4o-mini enrichment, idempotent DB save - POST /api/timeline/bootstrap-ma endpoint to trigger analysis - Bootstrap MA button in Timeline page with loading state + result count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -209,3 +209,15 @@ def bootstrap_events() -> Dict[str, Any]:
|
|||||||
from services.timeline_service import bootstrap_events as _bootstrap
|
from services.timeline_service import bootstrap_events as _bootstrap
|
||||||
count = _bootstrap()
|
count = _bootstrap()
|
||||||
return {"seeded": count, "status": "ok" if count > 0 else "already_seeded"}
|
return {"seeded": count, "status": "ok" if count > 0 else "already_seeded"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bootstrap-ma")
|
||||||
|
async def bootstrap_ma() -> Dict[str, Any]:
|
||||||
|
"""Detect MA ruptures on 5 key underlyings and generate historical market events via GPT."""
|
||||||
|
from services.ma_analyzer import bootstrap_ma_events
|
||||||
|
try:
|
||||||
|
result = await bootstrap_ma_events()
|
||||||
|
return {**result, "status": "ok"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Timeline] bootstrap-ma failed: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|||||||
570
backend/services/ma_analyzer.py
Normal file
570
backend/services/ma_analyzer.py
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
"""
|
||||||
|
MA Analyzer — Bootstrap historical market periods from price-based MA signals.
|
||||||
|
|
||||||
|
Fetches 5y daily prices for 5 key underlyings, detects MA ruptures (golden/death
|
||||||
|
cross, MA100 slope changes, MA20 direction changes), enriches candidates with
|
||||||
|
GPT-4o-mini, and persists them via save_market_event().
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
from services.database import count_market_events, save_market_event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNDERLYINGS = [
|
||||||
|
{"ticker": "EURUSD=X", "name": "EUR/USD", "category": "market", "assets": ["EUR/USD", "USD", "EUR"]},
|
||||||
|
{"ticker": "BZ=F", "name": "Brent Crude", "category": "market", "assets": ["CL", "BZ", "Oil"]},
|
||||||
|
{"ticker": "GC=F", "name": "Gold", "category": "market", "assets": ["GC", "Gold", "GLD"]},
|
||||||
|
{"ticker": "^GSPC", "name": "S&P 500", "category": "market", "assets": ["SPX", "SPY", "QQQ"]},
|
||||||
|
{"ticker": "^TNX", "name": "US 10Y Yield", "category": "market", "assets": ["TNX", "TLT", "Bonds"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
MA_PERIODS = [20, 50, 100, 200]
|
||||||
|
CROSS_CONFIRM_DAYS = 5
|
||||||
|
SLOPE_WINDOW = 10
|
||||||
|
DEDUP_DAYS = 30
|
||||||
|
MAX_CANDIDATES = 80
|
||||||
|
GPT_BATCH_SIZE = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_prices() -> Dict[str, pd.Series]:
|
||||||
|
"""Download 5y daily close prices for all underlyings. Returns ticker → pd.Series."""
|
||||||
|
tickers = [u["ticker"] for u in UNDERLYINGS]
|
||||||
|
prices: Dict[str, pd.Series] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = yf.download(tickers, period="5y", interval="1d", progress=False, auto_adjust=True)
|
||||||
|
if raw.empty:
|
||||||
|
raise ValueError("Empty download result")
|
||||||
|
close = raw["Close"] if isinstance(raw.columns, pd.MultiIndex) else raw
|
||||||
|
for ticker in tickers:
|
||||||
|
if ticker in close.columns:
|
||||||
|
s = close[ticker].dropna()
|
||||||
|
if not s.empty:
|
||||||
|
prices[ticker] = s
|
||||||
|
logger.info(f"[MA] {ticker}: {len(s)} bars")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MA] Batch download failed ({e}), falling back to individual")
|
||||||
|
for ticker in tickers:
|
||||||
|
try:
|
||||||
|
df = yf.download(ticker, period="5y", interval="1d", progress=False, auto_adjust=True)
|
||||||
|
if df.empty:
|
||||||
|
continue
|
||||||
|
if isinstance(df.columns, pd.MultiIndex):
|
||||||
|
df.columns = df.columns.get_level_values(0)
|
||||||
|
s = df["Close"].dropna()
|
||||||
|
if not s.empty:
|
||||||
|
prices[ticker] = s
|
||||||
|
logger.info(f"[MA] {ticker}: {len(s)} bars")
|
||||||
|
except Exception as e2:
|
||||||
|
logger.error(f"[MA] Failed to fetch {ticker}: {e2}")
|
||||||
|
|
||||||
|
return prices
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_mas(prices: pd.Series) -> pd.DataFrame:
|
||||||
|
"""Return DataFrame with Close + MA20/50/100/200 columns."""
|
||||||
|
df = prices.to_frame(name="Close")
|
||||||
|
for p in MA_PERIODS:
|
||||||
|
df[f"MA{p}"] = df["Close"].rolling(p).mean()
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def _date_str(ts) -> str:
|
||||||
|
if hasattr(ts, "strftime"):
|
||||||
|
return ts.strftime("%Y-%m-%d")
|
||||||
|
return str(ts)[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def _price_move_pct(df: pd.DataFrame, start_idx: int, end_idx: int) -> float:
|
||||||
|
"""Absolute % move of Close between two integer iloc positions."""
|
||||||
|
try:
|
||||||
|
p0 = float(df["Close"].iloc[start_idx])
|
||||||
|
p1 = float(df["Close"].iloc[end_idx])
|
||||||
|
if p0 == 0:
|
||||||
|
return 0.0
|
||||||
|
return abs((p1 - p0) / p0 * 100)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_crossovers(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""Detect MA50/MA200 golden cross and death cross with 5-day confirmation."""
|
||||||
|
candidates = []
|
||||||
|
ma50 = df["MA50"]
|
||||||
|
ma200 = df["MA200"]
|
||||||
|
|
||||||
|
valid = df.dropna(subset=["MA50", "MA200"])
|
||||||
|
if len(valid) < CROSS_CONFIRM_DAYS + 2:
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
idx_list = valid.index.tolist()
|
||||||
|
|
||||||
|
i = 1
|
||||||
|
while i < len(idx_list) - CROSS_CONFIRM_DAYS:
|
||||||
|
prev_i = idx_list[i - 1]
|
||||||
|
curr_i = idx_list[i]
|
||||||
|
|
||||||
|
prev_above = ma50[prev_i] > ma200[prev_i]
|
||||||
|
curr_above = ma50[curr_i] > ma200[curr_i]
|
||||||
|
|
||||||
|
if prev_above == curr_above:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_golden = curr_above # MA50 just crossed above MA200
|
||||||
|
|
||||||
|
# Confirm: next CROSS_CONFIRM_DAYS all maintain the new relationship
|
||||||
|
confirm_ok = True
|
||||||
|
for k in range(1, CROSS_CONFIRM_DAYS + 1):
|
||||||
|
if i + k >= len(idx_list):
|
||||||
|
confirm_ok = False
|
||||||
|
break
|
||||||
|
future_i = idx_list[i + k]
|
||||||
|
if is_golden:
|
||||||
|
if not (ma50[future_i] > ma200[future_i]):
|
||||||
|
confirm_ok = False
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if not (ma50[future_i] < ma200[future_i]):
|
||||||
|
confirm_ok = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if confirm_ok:
|
||||||
|
# start_date = date of the confirming bar (i + CROSS_CONFIRM_DAYS)
|
||||||
|
confirmed_i = idx_list[i + CROSS_CONFIRM_DAYS]
|
||||||
|
start_iloc = valid.index.get_loc(confirmed_i)
|
||||||
|
|
||||||
|
event_type = "golden_cross" if is_golden else "death_cross"
|
||||||
|
name_prefix = "Golden Cross" if is_golden else "Death Cross"
|
||||||
|
candidates.append({
|
||||||
|
"_type": event_type,
|
||||||
|
"_underlying": underlying,
|
||||||
|
"_start_iloc": start_iloc,
|
||||||
|
"start_date": _date_str(confirmed_i),
|
||||||
|
"end_date": None,
|
||||||
|
"level": "medium",
|
||||||
|
"category": "market",
|
||||||
|
"_move_magnitude": _price_move_pct(df, max(0, start_iloc - 20), start_iloc),
|
||||||
|
"_name_hint": f"{underlying['name']} {name_prefix} MA50/MA200",
|
||||||
|
})
|
||||||
|
i += CROSS_CONFIRM_DAYS + 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _slope(series: pd.Series, window: int = SLOPE_WINDOW) -> pd.Series:
|
||||||
|
"""Percentage slope over window: (val[t] - val[t-window]) / val[t-window] * 100."""
|
||||||
|
return (series - series.shift(window)) / series.shift(window) * 100
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_ma100_slope_changes(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""Detect MA100 direction reversals (slope sign flips sustained over 5+ bars)."""
|
||||||
|
candidates = []
|
||||||
|
valid = df.dropna(subset=["MA100"])
|
||||||
|
if len(valid) < SLOPE_WINDOW + 10:
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
slope = _slope(valid["MA100"], SLOPE_WINDOW)
|
||||||
|
sign = np.sign(slope)
|
||||||
|
|
||||||
|
idx_list = valid.index.tolist()
|
||||||
|
i = 1
|
||||||
|
while i < len(idx_list) - 5:
|
||||||
|
prev_sign = sign[idx_list[i - 1]]
|
||||||
|
curr_sign = sign[idx_list[i]]
|
||||||
|
|
||||||
|
if prev_sign == 0 or curr_sign == 0 or prev_sign == curr_sign:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check 5-bar confirmation
|
||||||
|
confirm_ok = all(
|
||||||
|
sign[idx_list[i + k]] == curr_sign
|
||||||
|
for k in range(1, 6)
|
||||||
|
if i + k < len(idx_list)
|
||||||
|
)
|
||||||
|
if not confirm_ok:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_idx = valid.index.get_loc(idx_list[i])
|
||||||
|
|
||||||
|
# Find end: next sign flip
|
||||||
|
end_date = None
|
||||||
|
duration_days = 0
|
||||||
|
for j in range(i + 1, len(idx_list)):
|
||||||
|
if sign[idx_list[j]] == -curr_sign:
|
||||||
|
end_date = _date_str(idx_list[j])
|
||||||
|
duration_days = (
|
||||||
|
datetime.strptime(end_date, "%Y-%m-%d") -
|
||||||
|
datetime.strptime(_date_str(idx_list[i]), "%Y-%m-%d")
|
||||||
|
).days
|
||||||
|
break
|
||||||
|
|
||||||
|
direction = "Bullish" if curr_sign > 0 else "Bearish"
|
||||||
|
level = "long" if duration_days > 90 or end_date is None else "medium"
|
||||||
|
|
||||||
|
candidates.append({
|
||||||
|
"_type": "ma100_slope_change",
|
||||||
|
"_underlying": underlying,
|
||||||
|
"_start_iloc": start_idx,
|
||||||
|
"start_date": _date_str(idx_list[i]),
|
||||||
|
"end_date": end_date,
|
||||||
|
"level": level,
|
||||||
|
"category": "market",
|
||||||
|
"_move_magnitude": abs(float(slope[idx_list[i]])),
|
||||||
|
"_name_hint": f"{underlying['name']} MA100 {direction} Trend",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Skip to next potential flip zone
|
||||||
|
if end_date:
|
||||||
|
while i < len(idx_list) and _date_str(idx_list[i]) < end_date:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_ma20_direction_changes(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""Detect strong MA20 direction changes (> 2% in 10 days)."""
|
||||||
|
candidates = []
|
||||||
|
valid = df.dropna(subset=["MA20"])
|
||||||
|
if len(valid) < SLOPE_WINDOW + 10:
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
slope = _slope(valid["MA20"], SLOPE_WINDOW)
|
||||||
|
THRESHOLD = 2.0
|
||||||
|
|
||||||
|
idx_list = valid.index.tolist()
|
||||||
|
last_added_date = None
|
||||||
|
|
||||||
|
for i in range(1, len(idx_list) - 5):
|
||||||
|
prev_slope = float(slope[idx_list[i - 1]])
|
||||||
|
curr_slope = float(slope[idx_list[i]])
|
||||||
|
|
||||||
|
if abs(curr_slope) < THRESHOLD:
|
||||||
|
continue
|
||||||
|
if np.sign(prev_slope) == np.sign(curr_slope):
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_date = _date_str(idx_list[i])
|
||||||
|
|
||||||
|
# Dedup: skip if too close to last added
|
||||||
|
if last_added_date:
|
||||||
|
gap = (
|
||||||
|
datetime.strptime(start_date, "%Y-%m-%d") -
|
||||||
|
datetime.strptime(last_added_date, "%Y-%m-%d")
|
||||||
|
).days
|
||||||
|
if gap < DEDUP_DAYS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find end: slope returns to < 0.5 or reverses
|
||||||
|
end_date = None
|
||||||
|
duration_days = 0
|
||||||
|
for j in range(i + 1, len(idx_list)):
|
||||||
|
future_slope = float(slope[idx_list[j]])
|
||||||
|
if abs(future_slope) < 0.5 or np.sign(future_slope) != np.sign(curr_slope):
|
||||||
|
end_date = _date_str(idx_list[j])
|
||||||
|
duration_days = (
|
||||||
|
datetime.strptime(end_date, "%Y-%m-%d") -
|
||||||
|
datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
).days
|
||||||
|
break
|
||||||
|
|
||||||
|
direction = "Bullish" if curr_slope > 0 else "Bearish"
|
||||||
|
level = "short" if (end_date and duration_days < 30) else "medium"
|
||||||
|
|
||||||
|
candidates.append({
|
||||||
|
"_type": "ma20_direction_change",
|
||||||
|
"_underlying": underlying,
|
||||||
|
"_start_iloc": valid.index.get_loc(idx_list[i]),
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"level": level,
|
||||||
|
"category": "market",
|
||||||
|
"_move_magnitude": abs(curr_slope),
|
||||||
|
"_name_hint": f"{underlying['name']} MA20 {direction} Swing",
|
||||||
|
})
|
||||||
|
last_added_date = start_date
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _dedup_candidates(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""Remove near-duplicate events (same underlying + type, dates < 30 days apart).
|
||||||
|
Keep the one with the largest _move_magnitude."""
|
||||||
|
kept: List[Dict[str, Any]] = []
|
||||||
|
for ev in candidates:
|
||||||
|
ticker = ev["_underlying"]["ticker"]
|
||||||
|
ev_type = ev["_type"]
|
||||||
|
ev_start = ev["start_date"]
|
||||||
|
|
||||||
|
duplicate_of = None
|
||||||
|
for k in kept:
|
||||||
|
if k["_underlying"]["ticker"] != ticker:
|
||||||
|
continue
|
||||||
|
if k["_type"] != ev_type:
|
||||||
|
continue
|
||||||
|
gap = abs(
|
||||||
|
(datetime.strptime(ev_start, "%Y-%m-%d") -
|
||||||
|
datetime.strptime(k["start_date"], "%Y-%m-%d")).days
|
||||||
|
)
|
||||||
|
if gap < DEDUP_DAYS:
|
||||||
|
duplicate_of = k
|
||||||
|
break
|
||||||
|
|
||||||
|
if duplicate_of is None:
|
||||||
|
kept.append(ev)
|
||||||
|
elif ev.get("_move_magnitude", 0) > duplicate_of.get("_move_magnitude", 0):
|
||||||
|
kept.remove(duplicate_of)
|
||||||
|
kept.append(ev)
|
||||||
|
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_end_dates(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""For events without an end_date, set it to the next same-type event on same underlying."""
|
||||||
|
for i, ev in enumerate(candidates):
|
||||||
|
if ev.get("end_date"):
|
||||||
|
continue
|
||||||
|
ticker = ev["_underlying"]["ticker"]
|
||||||
|
ev_type = ev["_type"]
|
||||||
|
ev_start = ev["start_date"]
|
||||||
|
|
||||||
|
# Find the next event of same type/underlying that comes after
|
||||||
|
next_start = None
|
||||||
|
for j, other in enumerate(candidates):
|
||||||
|
if i == j:
|
||||||
|
continue
|
||||||
|
if other["_underlying"]["ticker"] != ticker:
|
||||||
|
continue
|
||||||
|
if other["_type"] != ev_type:
|
||||||
|
continue
|
||||||
|
if other["start_date"] > ev_start:
|
||||||
|
if next_start is None or other["start_date"] < next_start:
|
||||||
|
next_start = other["start_date"]
|
||||||
|
|
||||||
|
ev["end_date"] = next_start # None if it's the last (ongoing)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gpt_prompt(batch: List[Dict[str, Any]], df_map: Dict[str, pd.DataFrame]) -> str:
|
||||||
|
items = []
|
||||||
|
for i, ev in enumerate(batch):
|
||||||
|
underlying = ev["_underlying"]
|
||||||
|
ticker = underlying["ticker"]
|
||||||
|
df = df_map.get(ticker)
|
||||||
|
start_price = end_price = None
|
||||||
|
if df is not None:
|
||||||
|
try:
|
||||||
|
start_idx = df.index.searchsorted(pd.Timestamp(ev["start_date"]))
|
||||||
|
start_price = float(df["Close"].iloc[min(start_idx, len(df) - 1)])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if ev.get("end_date"):
|
||||||
|
try:
|
||||||
|
end_idx = df.index.searchsorted(pd.Timestamp(ev["end_date"]))
|
||||||
|
end_price = float(df["Close"].iloc[min(end_idx, len(df) - 1)])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"index": i,
|
||||||
|
"underlying": underlying["name"],
|
||||||
|
"type": ev["_type"],
|
||||||
|
"start_date": ev["start_date"],
|
||||||
|
"end_date": ev.get("end_date"),
|
||||||
|
"level": ev["level"],
|
||||||
|
"name_hint": ev["_name_hint"],
|
||||||
|
"price_start": round(start_price, 4) if start_price else None,
|
||||||
|
"price_end": round(end_price, 4) if end_price else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return f"""You are a senior macro analyst specializing in technical market regimes.
|
||||||
|
Analyze these {len(batch)} market events detected via Moving Average signals.
|
||||||
|
For each, return a JSON object with:
|
||||||
|
- index: (same as input)
|
||||||
|
- name: concise English event name (ex: "S&P 500 Golden Cross Bull Trend")
|
||||||
|
- description: 2-3 sentences explaining market context and significance
|
||||||
|
- market_impact: 1 sentence on trading/volatility impact
|
||||||
|
- affected_assets: list of 3-6 ticker symbols most affected
|
||||||
|
- impact_score: float 0.0-1.0 (0=minor technical, 1=major structural)
|
||||||
|
|
||||||
|
Events to analyze:
|
||||||
|
{json.dumps(items, indent=2)}
|
||||||
|
|
||||||
|
Return a JSON array of {len(batch)} objects, one per input event, in the same order.
|
||||||
|
Array only, no wrapping key."""
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_with_gpt(candidates: List[Dict[str, Any]], df_map: Dict[str, pd.DataFrame]) -> List[Dict[str, Any]]:
|
||||||
|
"""Call GPT-4o-mini in batches of GPT_BATCH_SIZE to enrich candidates."""
|
||||||
|
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("[MA] No OPENAI_API_KEY — skipping GPT enrichment")
|
||||||
|
for ev in candidates:
|
||||||
|
ev["name"] = ev["_name_hint"]
|
||||||
|
ev["description"] = f"MA signal detected on {ev['_underlying']['name']}."
|
||||||
|
ev["market_impact"] = "Technical price action signal."
|
||||||
|
ev["affected_assets"] = ev["_underlying"]["assets"]
|
||||||
|
ev["impact_score"] = 0.4
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
client = OpenAI(api_key=api_key)
|
||||||
|
|
||||||
|
for batch_start in range(0, len(candidates), GPT_BATCH_SIZE):
|
||||||
|
batch = candidates[batch_start: batch_start + GPT_BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
prompt = _build_gpt_prompt(batch, df_map)
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=1200,
|
||||||
|
)
|
||||||
|
raw = response.choices[0].message.content.strip()
|
||||||
|
# Strip markdown code block if present
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = raw.split("```")[1]
|
||||||
|
if raw.startswith("json"):
|
||||||
|
raw = raw[4:]
|
||||||
|
results = json.loads(raw)
|
||||||
|
for item in results:
|
||||||
|
idx = item.get("index")
|
||||||
|
if idx is not None and 0 <= idx < len(batch):
|
||||||
|
ev = batch[idx]
|
||||||
|
ev["name"] = item.get("name", ev["_name_hint"])
|
||||||
|
ev["description"] = item.get("description", "")
|
||||||
|
ev["market_impact"] = item.get("market_impact", "")
|
||||||
|
ev["affected_assets"] = item.get("affected_assets", ev["_underlying"]["assets"])
|
||||||
|
ev["impact_score"] = float(item.get("impact_score", 0.4))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MA] GPT batch {batch_start // GPT_BATCH_SIZE + 1} failed: {e}")
|
||||||
|
for ev in batch:
|
||||||
|
if "name" not in ev:
|
||||||
|
ev["name"] = ev["_name_hint"]
|
||||||
|
ev["description"] = f"MA signal on {ev['_underlying']['name']}."
|
||||||
|
ev["market_impact"] = "Technical signal."
|
||||||
|
ev["affected_assets"] = ev["_underlying"]["assets"]
|
||||||
|
ev["impact_score"] = 0.4
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Ensure all events have required fields
|
||||||
|
for ev in candidates:
|
||||||
|
if "name" not in ev:
|
||||||
|
ev["name"] = ev["_name_hint"]
|
||||||
|
ev["description"] = f"MA signal on {ev['_underlying']['name']}."
|
||||||
|
ev["market_impact"] = "Technical signal."
|
||||||
|
ev["affected_assets"] = ev["_underlying"]["assets"]
|
||||||
|
ev["impact_score"] = 0.4
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _to_event_dict(ev: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Convert internal candidate to the format expected by save_market_event."""
|
||||||
|
return {
|
||||||
|
"name": ev["name"],
|
||||||
|
"start_date": ev["start_date"],
|
||||||
|
"end_date": ev.get("end_date"),
|
||||||
|
"level": ev["level"],
|
||||||
|
"category": ev.get("category", "market"),
|
||||||
|
"description": ev.get("description", ""),
|
||||||
|
"market_impact": ev.get("market_impact", ""),
|
||||||
|
"affected_assets": ev.get("affected_assets", []),
|
||||||
|
"impact_score": ev.get("impact_score", 0.4),
|
||||||
|
"absorption_pct": None,
|
||||||
|
"relevant_indicators": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap_ma_events() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Main entrypoint. Detects MA ruptures, enriches with GPT, saves to DB.
|
||||||
|
Idempotent: skips if DB already has > 100 events.
|
||||||
|
Returns {"detected": N, "saved": N, "skipped": N}.
|
||||||
|
"""
|
||||||
|
existing = count_market_events()
|
||||||
|
if existing > 100:
|
||||||
|
logger.info(f"[MA] DB already has {existing} events — skipping bootstrap")
|
||||||
|
return {"detected": 0, "saved": 0, "skipped": existing}
|
||||||
|
|
||||||
|
logger.info("[MA] Fetching price data...")
|
||||||
|
prices = _fetch_prices()
|
||||||
|
if not prices:
|
||||||
|
logger.error("[MA] No price data fetched — aborting")
|
||||||
|
return {"detected": 0, "saved": 0, "skipped": 0}
|
||||||
|
|
||||||
|
df_map: Dict[str, pd.DataFrame] = {}
|
||||||
|
all_candidates: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for underlying in UNDERLYINGS:
|
||||||
|
ticker = underlying["ticker"]
|
||||||
|
if ticker not in prices:
|
||||||
|
logger.warning(f"[MA] No data for {ticker}, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = _compute_mas(prices[ticker])
|
||||||
|
df_map[ticker] = df
|
||||||
|
|
||||||
|
cross_evs = _detect_crossovers(df, underlying)
|
||||||
|
slope_evs = _detect_ma100_slope_changes(df, underlying)
|
||||||
|
swing_evs = _detect_ma20_direction_changes(df, underlying)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[MA] {underlying['name']}: crossovers={len(cross_evs)}, "
|
||||||
|
f"slope={len(slope_evs)}, swing={len(swing_evs)}"
|
||||||
|
)
|
||||||
|
all_candidates.extend(cross_evs + slope_evs + swing_evs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MA] Detection failed for {underlying['name']}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"[MA] Raw candidates: {len(all_candidates)}")
|
||||||
|
|
||||||
|
# Dedup + assign end dates
|
||||||
|
candidates = _dedup_candidates(all_candidates)
|
||||||
|
candidates = _assign_end_dates(candidates)
|
||||||
|
|
||||||
|
# Sort by start_date ascending for GPT context coherence
|
||||||
|
candidates.sort(key=lambda x: x["start_date"])
|
||||||
|
|
||||||
|
# Cap at MAX_CANDIDATES (keep most significant by _move_magnitude)
|
||||||
|
if len(candidates) > MAX_CANDIDATES:
|
||||||
|
candidates.sort(key=lambda x: x.get("_move_magnitude", 0), reverse=True)
|
||||||
|
candidates = candidates[:MAX_CANDIDATES]
|
||||||
|
candidates.sort(key=lambda x: x["start_date"])
|
||||||
|
|
||||||
|
detected = len(candidates)
|
||||||
|
logger.info(f"[MA] After dedup/cap: {detected} candidates — enriching with GPT...")
|
||||||
|
|
||||||
|
candidates = _enrich_with_gpt(candidates, df_map)
|
||||||
|
|
||||||
|
saved = 0
|
||||||
|
skipped_save = 0
|
||||||
|
for ev in candidates:
|
||||||
|
try:
|
||||||
|
save_market_event(_to_event_dict(ev))
|
||||||
|
saved += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MA] Failed to save '{ev.get('name', '?')}': {e}")
|
||||||
|
skipped_save += 1
|
||||||
|
|
||||||
|
logger.info(f"[MA] Bootstrap complete: detected={detected} saved={saved} skipped={skipped_save}")
|
||||||
|
return {"detected": detected, "saved": saved, "skipped": skipped_save}
|
||||||
308
frontend/src/components/TimelineVertical.tsx
Normal file
308
frontend/src/components/TimelineVertical.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useRef, useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
|
interface MarketEvent {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
start_date: string
|
||||||
|
end_date: string | null
|
||||||
|
level: 'long' | 'medium' | 'short'
|
||||||
|
category: string
|
||||||
|
impact_score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: MarketEvent[]
|
||||||
|
refDate: string
|
||||||
|
onDateChange: (d: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCALE = 1.8 // px per day
|
||||||
|
const MARGIN_LEFT = 52 // year label area
|
||||||
|
const LEVEL_W = 190 // fixed px per level column
|
||||||
|
const SUBCOL_GAP = 2
|
||||||
|
const COL_GAP = 8
|
||||||
|
const CONTAINER_H = 560
|
||||||
|
const MIN_EV_H = 20
|
||||||
|
const FRISE_START = '2020-02-01'
|
||||||
|
const LEVEL_ORDER = ['long', 'medium', 'short'] as const
|
||||||
|
|
||||||
|
type Level = typeof LEVEL_ORDER[number]
|
||||||
|
|
||||||
|
const COLORS: Record<Level, { bg: string; border: string; text: string; track: string }> = {
|
||||||
|
long: { bg: '#2e1065', border: '#7c3aed', text: '#ede9fe', track: 'rgba(109,40,217,0.07)' },
|
||||||
|
medium: { bg: '#1e3a5f', border: '#3b82f6', text: '#dbeafe', track: 'rgba(59,130,246,0.07)' },
|
||||||
|
short: { bg: '#064e3b', border: '#10b981', text: '#d1fae5', track: 'rgba(16,185,129,0.07)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<Level, string> = {
|
||||||
|
long: 'Long Terme',
|
||||||
|
medium: 'Moyen Terme',
|
||||||
|
short: 'Court Terme',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDay(date: string): number {
|
||||||
|
const origin = new Date(FRISE_START).getTime()
|
||||||
|
return Math.floor((new Date(date + 'T00:00:00Z').getTime() - origin) / 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignSubCols(evs: MarketEvent[]): Map<number, number> {
|
||||||
|
const sorted = [...evs].sort((a, b) => a.start_date.localeCompare(b.start_date))
|
||||||
|
const colEnds: string[] = []
|
||||||
|
const result = new Map<number, number>()
|
||||||
|
for (const ev of sorted) {
|
||||||
|
const end = ev.end_date ?? '2099-12-31'
|
||||||
|
let placed = false
|
||||||
|
for (let i = 0; i < colEnds.length; i++) {
|
||||||
|
if (ev.start_date >= colEnds[i]) {
|
||||||
|
result.set(ev.id, i); colEnds[i] = end; placed = true; break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) { result.set(ev.id, colEnds.length); colEnds.push(end) }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelineVertical({ events, refDate, onDateChange }: Props) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const today = useMemo(() => new Date().toISOString().split('T')[0], [])
|
||||||
|
const todayDay = toDay(today)
|
||||||
|
const totalDays = todayDay + 60
|
||||||
|
const totalHeight = totalDays * SCALE
|
||||||
|
const refY = toDay(refDate) * SCALE
|
||||||
|
const todayY = todayDay * SCALE
|
||||||
|
|
||||||
|
const yearMarkers = useMemo(() => {
|
||||||
|
const origin = new Date(FRISE_START)
|
||||||
|
const out: { day: number; label: string }[] = []
|
||||||
|
for (let y = origin.getFullYear(); y <= origin.getFullYear() + Math.ceil(totalDays / 365) + 1; y++) {
|
||||||
|
const jan1 = new Date(y, 0, 1)
|
||||||
|
const day = Math.floor((jan1.getTime() - origin.getTime()) / 86_400_000)
|
||||||
|
if (day >= 0 && day <= totalDays) out.push({ day, label: String(y) })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [totalDays])
|
||||||
|
|
||||||
|
const byLevel = useMemo(() => {
|
||||||
|
const g: Record<Level, MarketEvent[]> = { long: [], medium: [], short: [] }
|
||||||
|
for (const ev of events) {
|
||||||
|
const l = ev.level as Level
|
||||||
|
if (g[l]) g[l].push(ev)
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}, [events])
|
||||||
|
|
||||||
|
const subColsMap = useMemo(() => {
|
||||||
|
const m = {} as Record<Level, Map<number, number>>
|
||||||
|
for (const l of LEVEL_ORDER) m[l] = assignSubCols(byLevel[l])
|
||||||
|
return m
|
||||||
|
}, [byLevel])
|
||||||
|
|
||||||
|
const maxSubCols = useMemo(() => {
|
||||||
|
const m = { long: 1, medium: 1, short: 1 } as Record<Level, number>
|
||||||
|
for (const l of LEVEL_ORDER) {
|
||||||
|
const vals = [...subColsMap[l].values()]
|
||||||
|
if (vals.length) m[l] = Math.min(Math.max(...vals) + 1, 4) // cap at 4 sub-cols
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}, [subColsMap])
|
||||||
|
|
||||||
|
const colX = useMemo(() => {
|
||||||
|
const x = {} as Record<Level, number>
|
||||||
|
let cur = MARGIN_LEFT
|
||||||
|
for (const l of LEVEL_ORDER) { x[l] = cur; cur += LEVEL_W + COL_GAP }
|
||||||
|
return x
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const totalWidth = MARGIN_LEFT + LEVEL_ORDER.length * (LEVEL_W + COL_GAP)
|
||||||
|
|
||||||
|
function subColW(level: Level): number {
|
||||||
|
const n = maxSubCols[level]
|
||||||
|
return Math.max((LEVEL_W - (n - 1) * SUBCOL_GAP) / n, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.scrollTop = Math.max(0, refY - el.clientHeight / 2)
|
||||||
|
}, [refDate, refY])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden">
|
||||||
|
{/* Column headers */}
|
||||||
|
<div className="flex border-b border-slate-700/30" style={{ paddingLeft: MARGIN_LEFT + 'px' }}>
|
||||||
|
{LEVEL_ORDER.map((l, i) => (
|
||||||
|
<div
|
||||||
|
key={l}
|
||||||
|
className="flex items-center justify-center text-xs font-semibold py-2.5 shrink-0"
|
||||||
|
style={{
|
||||||
|
width: LEVEL_W + 'px',
|
||||||
|
marginRight: i < LEVEL_ORDER.length - 1 ? COL_GAP + 'px' : 0,
|
||||||
|
color: COLORS[l].text,
|
||||||
|
borderBottom: `2px solid ${COLORS[l].border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[l]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable area */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ height: CONTAINER_H + 'px', overflowY: 'auto', overflowX: 'hidden', position: 'relative' }}
|
||||||
|
className="scrollbar-thin"
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', height: totalHeight + 'px', width: totalWidth + 'px', minWidth: '100%' }}>
|
||||||
|
|
||||||
|
{/* Column track backgrounds */}
|
||||||
|
{LEVEL_ORDER.map(l => (
|
||||||
|
<div key={l} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
height: totalHeight + 'px',
|
||||||
|
left: colX[l],
|
||||||
|
width: LEVEL_W + 'px',
|
||||||
|
background: COLORS[l].track,
|
||||||
|
borderLeft: `1px solid ${COLORS[l].border}18`,
|
||||||
|
borderRight: `1px solid ${COLORS[l].border}18`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Year lines + labels */}
|
||||||
|
{yearMarkers.map(m => (
|
||||||
|
<div key={m.label} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: m.day * SCALE,
|
||||||
|
left: 0,
|
||||||
|
width: totalWidth + 'px',
|
||||||
|
height: 1,
|
||||||
|
background: 'rgba(255,255,255,0.055)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 3,
|
||||||
|
top: 2,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'rgba(100,116,139,0.9)',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
{events.map(ev => {
|
||||||
|
const l = ev.level as Level
|
||||||
|
const c = COLORS[l]
|
||||||
|
const sc = subColsMap[l].get(ev.id) ?? 0
|
||||||
|
// cap sub-col index to max allowed
|
||||||
|
const cappedSc = Math.min(sc, maxSubCols[l] - 1)
|
||||||
|
const scw = subColW(l)
|
||||||
|
const x = colX[l] + cappedSc * (scw + SUBCOL_GAP)
|
||||||
|
const topY = toDay(ev.start_date) * SCALE
|
||||||
|
const endDay = ev.end_date ? toDay(ev.end_date) : totalDays
|
||||||
|
const h = Math.max((endDay - toDay(ev.start_date)) * SCALE, MIN_EV_H)
|
||||||
|
const isActive = refDate >= ev.start_date && (!ev.end_date || refDate <= ev.end_date)
|
||||||
|
const isOngoing = !ev.end_date
|
||||||
|
const daysSinceStart = Math.max(0, Math.floor(
|
||||||
|
(new Date(refDate).getTime() - new Date(ev.start_date).getTime()) / 86_400_000
|
||||||
|
))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
onClick={() => onDateChange(ev.start_date)}
|
||||||
|
title={`[${ev.level.toUpperCase()}] ${ev.name}\n${ev.start_date}${ev.end_date ? ' → ' + ev.end_date : ' → en cours'}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: topY + 1,
|
||||||
|
left: x + 1,
|
||||||
|
width: scw - 2,
|
||||||
|
height: h - 2,
|
||||||
|
background: isActive ? c.border : c.bg,
|
||||||
|
border: `1px solid ${isActive ? c.border : c.border + '88'}`,
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
opacity: isActive ? 1 : 0.7,
|
||||||
|
zIndex: isActive ? 10 : 2,
|
||||||
|
backgroundImage: isOngoing && !isActive
|
||||||
|
? `linear-gradient(180deg, ${c.bg} 80%, ${c.border}30 100%)`
|
||||||
|
: undefined,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '2px 4px',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: c.text,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{ev.name}
|
||||||
|
</div>
|
||||||
|
{h > 46 && isActive && (
|
||||||
|
<div style={{ padding: '0 4px', fontSize: 8, color: c.text + 'bb' }}>
|
||||||
|
J+{daysSinceStart}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOngoing && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 1,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontSize: 8,
|
||||||
|
color: c.border + 'cc',
|
||||||
|
}}>▼</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Today line */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: todayY,
|
||||||
|
left: 0,
|
||||||
|
width: totalWidth + 'px',
|
||||||
|
height: 2,
|
||||||
|
background: 'rgba(239,68,68,0.75)',
|
||||||
|
zIndex: 20,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 4,
|
||||||
|
top: 3,
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#f87171',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}>aujourd'hui</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected date line */}
|
||||||
|
{refDate !== today && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: refY,
|
||||||
|
left: 0,
|
||||||
|
width: totalWidth + 'px',
|
||||||
|
height: 0,
|
||||||
|
borderTop: '1.5px dashed rgba(255,255,255,0.38)',
|
||||||
|
zIndex: 20,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List, Cpu } from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import TimelineFrise from '../components/TimelineFrise'
|
import TimelineVertical from '../components/TimelineVertical'
|
||||||
import EventManager from '../components/EventManager'
|
import EventManager from '../components/EventManager'
|
||||||
|
|
||||||
const api = axios.create({ baseURL: '/api' })
|
const api = axios.create({ baseURL: '/api' })
|
||||||
@@ -191,6 +191,8 @@ export default function Timeline() {
|
|||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const [bootstrapped, setBootstrapped] = useState(false)
|
const [bootstrapped, setBootstrapped] = useState(false)
|
||||||
const [showManager, setShowManager] = useState(false)
|
const [showManager, setShowManager] = useState(false)
|
||||||
|
const [bootstrappingMA, setBootstrappingMA] = useState(false)
|
||||||
|
const [maResult, setMaResult] = useState<{ detected: number; saved: number } | null>(null)
|
||||||
|
|
||||||
const fetchDay = useCallback(async (d: string) => {
|
const fetchDay = useCallback(async (d: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -250,6 +252,18 @@ export default function Timeline() {
|
|||||||
}
|
}
|
||||||
}, [allEvents.length, bootstrapped, bootstrap])
|
}, [allEvents.length, bootstrapped, bootstrap])
|
||||||
|
|
||||||
|
const bootstrapMA = useCallback(async () => {
|
||||||
|
setBootstrappingMA(true)
|
||||||
|
setMaResult(null)
|
||||||
|
try {
|
||||||
|
const res = await api.post('/timeline/bootstrap-ma')
|
||||||
|
setMaResult({ detected: res.data.detected, saved: res.data.saved })
|
||||||
|
await fetchEvents()
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
setBootstrappingMA(false)
|
||||||
|
}
|
||||||
|
}, [fetchEvents])
|
||||||
|
|
||||||
const navigate = (days: number) => setRefDate(prev => offsetDate(prev, days))
|
const navigate = (days: number) => setRefDate(prev => offsetDate(prev, days))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -278,6 +292,20 @@ export default function Timeline() {
|
|||||||
<List className="w-3 h-3" />
|
<List className="w-3 h-3" />
|
||||||
Gérer événements
|
Gérer événements
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={bootstrapMA}
|
||||||
|
disabled={bootstrappingMA}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-emerald-400 border border-emerald-700/40 rounded-lg hover:bg-emerald-900/20 transition-colors disabled:opacity-40"
|
||||||
|
title="Analyser EUR/USD, Brent, Gold, S&P500, US10Y via MAs → générer les périodes via GPT"
|
||||||
|
>
|
||||||
|
<Cpu className="w-3 h-3" />
|
||||||
|
{bootstrappingMA ? 'Analyse MA...' : 'Bootstrap MA'}
|
||||||
|
</button>
|
||||||
|
{maResult && (
|
||||||
|
<span className="text-xs text-emerald-400/70">
|
||||||
|
{maResult.saved} périodes ajoutées
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => bootstrap()}
|
onClick={() => bootstrap()}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-400 border border-slate-700/40 rounded-lg hover:bg-dark-700 transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-400 border border-slate-700/40 rounded-lg hover:bg-dark-700 transition-colors"
|
||||||
@@ -342,9 +370,9 @@ export default function Timeline() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Frise chronologique */}
|
{/* Vertical timeline */}
|
||||||
{allEvents.length > 0 && (
|
{allEvents.length > 0 && (
|
||||||
<TimelineFrise events={allEvents} refDate={refDate} onDateChange={setRefDate} />
|
<TimelineVertical events={allEvents} refDate={refDate} onDateChange={setRefDate} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 3 context panels */}
|
{/* 3 context panels */}
|
||||||
|
|||||||
Reference in New Issue
Block a user