feat: cycle
This commit is contained in:
@@ -1754,6 +1754,79 @@ Réponds en JSON avec ce schéma EXACT:
|
||||
return report
|
||||
|
||||
|
||||
def generate_standalone_report() -> Dict[str, Any]:
|
||||
"""Cycle Actions — standalone "generate-report" action. _generate_cycle_report()
|
||||
itself is NOT modified (too tightly coupled to run_cycle_once()'s in-memory
|
||||
state to safely change) — instead every one of its parameters is
|
||||
reconstructed from an independent, DB-or-live source:
|
||||
- scored <- get_last_scores() (config key "last_pattern_scores",
|
||||
filled by save_pattern_scores() every real cycle)
|
||||
- news/geo_score <- same live sequence as cycle Step 1
|
||||
- dominant/scenarios/gauges <- same sequence as the update-regime action
|
||||
- commentary <- _generate_cycle_commentary(), independently callable
|
||||
- wavelet_signals <- compute_and_save_wavelet_signals(), independently callable
|
||||
- portfolio_monitor <- analyze_simulation_portfolio() (+ AI only if alerts)
|
||||
- added_patterns/options_assessment <- cycle-only artifacts, not
|
||||
reconstructible after the fact -> empty/None (report is honest, just
|
||||
thinner on these two fields than a live cycle's report)."""
|
||||
from services.database import get_config, get_pattern_scores, save_cycle_report
|
||||
from services.data_fetcher import fetch_geo_news, get_macro_gauges, score_macro_scenarios
|
||||
from services.geo_analyzer import compute_geo_risk_score
|
||||
from services.ai_analyzer import ai_score_news_batch, ai_score_geo_risk
|
||||
|
||||
run_id = datetime.utcnow().isoformat()
|
||||
ai_key = get_config("openai_api_key") or ""
|
||||
|
||||
last_scores = get_pattern_scores()
|
||||
scored = last_scores.get("scores") or []
|
||||
scoring_run_id = last_scores.get("run_id") or run_id
|
||||
|
||||
news = ai_score_news_batch(fetch_geo_news())
|
||||
algo_geo = compute_geo_risk_score(news)
|
||||
try:
|
||||
geo_obj = ai_score_geo_risk(news, algo_geo, log_meta={"run_id": run_id, "call_type": "geo_risk_score"})
|
||||
except Exception:
|
||||
geo_obj = algo_geo
|
||||
geo_score_val = int(round(geo_obj.get("score") or 0))
|
||||
|
||||
gauges = get_macro_gauges()
|
||||
scenarios = score_macro_scenarios(gauges)
|
||||
dominant = scenarios.get("dominant", "incertain")
|
||||
|
||||
commentary = None
|
||||
try:
|
||||
commentary = _generate_cycle_commentary(scored, dominant, scenarios, geo_score_val, news, gauges)
|
||||
except Exception as e:
|
||||
logger.warning(f"[StandaloneReport] Commentary generation failed: {e}")
|
||||
|
||||
wavelet_signals: List[Dict] = []
|
||||
try:
|
||||
from services.wavelet_signals import compute_and_save_wavelet_signals
|
||||
wavelet_signals = compute_and_save_wavelet_signals(run_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[StandaloneReport] Wavelet scan failed (non-blocking): {e}")
|
||||
|
||||
portfolio_monitor = None
|
||||
try:
|
||||
from services.portfolio_risk import analyze_simulation_portfolio
|
||||
risk = analyze_simulation_portfolio()
|
||||
if risk.get("alerts"):
|
||||
portfolio_monitor = _run_portfolio_monitor(risk, run_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[StandaloneReport] Portfolio monitor failed (non-blocking): {e}")
|
||||
|
||||
report = _generate_cycle_report(
|
||||
run_id=run_id, scored=scored, dominant=dominant, scenarios=scenarios,
|
||||
geo_score_val=geo_score_val, news=news, gauges=gauges, ai_key=ai_key,
|
||||
added_patterns=[], scoring_run_id=scoring_run_id,
|
||||
portfolio_monitor=portfolio_monitor, commentary=commentary,
|
||||
options_assessment=None, wavelet_signals=wavelet_signals,
|
||||
)
|
||||
if report:
|
||||
save_cycle_report(run_id, report)
|
||||
return {"run_id": run_id, "report": report}
|
||||
|
||||
|
||||
# ── Auto portfolio snapshot ───────────────────────────────────────────────────
|
||||
|
||||
def _auto_portfolio_snapshot(ai_key: str) -> None:
|
||||
|
||||
@@ -169,6 +169,23 @@ def init_db():
|
||||
"ALTER TABLE wavelet_watchlist_signals ADD COLUMN energy REAL",
|
||||
"ALTER TABLE wavelet_watchlist_signals ADD COLUMN ridge_period_days REAL",
|
||||
"ALTER TABLE wavelet_watchlist_signals ADD COLUMN params_json TEXT",
|
||||
# Cycle Actions — standalone "refresh-price-data" action, OHLCV cache
|
||||
"""CREATE TABLE IF NOT EXISTS price_data_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
open REAL, high REAL, low REAL, close REAL, volume REAL,
|
||||
cached_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(ticker, date)
|
||||
)""",
|
||||
# Cycle Actions — standalone "compute-indicators" action, one row per call per ticker
|
||||
"""CREATE TABLE IF NOT EXISTS instrument_indicators (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL,
|
||||
computed_at TEXT DEFAULT (datetime('now')),
|
||||
horizon_days INTEGER,
|
||||
indicators_json TEXT DEFAULT '{}'
|
||||
)""",
|
||||
]:
|
||||
try:
|
||||
c.execute(_sql)
|
||||
@@ -183,6 +200,14 @@ def init_db():
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_atp_session_status ON ai_trade_proposals(session_id, status)")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_pdc_ticker_date ON price_data_cache(ticker, date DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_ii_ticker_date ON instrument_indicators(ticker, computed_at DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_chat_session_date ON ai_chat_messages(session_id, created_at)")
|
||||
@@ -3441,6 +3466,72 @@ def resolve_ai_trade_proposal(proposal_id: str, status: str, portfolio_id: Optio
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Cycle Actions — standalone price data cache + indicator snapshots ──────────
|
||||
|
||||
def upsert_price_data(ticker: str, rows: List[Dict[str, Any]]) -> int:
|
||||
"""rows: [{date, open, high, low, close, volume}, ...]. Returns rows written."""
|
||||
if not rows:
|
||||
return 0
|
||||
conn = get_conn()
|
||||
n = 0
|
||||
for r in rows:
|
||||
conn.execute(
|
||||
"""INSERT INTO price_data_cache (ticker, date, open, high, low, close, volume, cached_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(ticker, date) DO UPDATE SET
|
||||
open=excluded.open, high=excluded.high, low=excluded.low,
|
||||
close=excluded.close, volume=excluded.volume, cached_at=excluded.cached_at""",
|
||||
(ticker.upper(), r.get("date"), r.get("open"), r.get("high"), r.get("low"), r.get("close"), r.get("volume")),
|
||||
)
|
||||
n += 1
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return n
|
||||
|
||||
|
||||
def get_cached_price_data(ticker: str, limit: int = 90) -> List[Dict[str, Any]]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM price_data_cache WHERE ticker=? ORDER BY date DESC LIMIT ?",
|
||||
(ticker.upper(), limit),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def save_instrument_indicators(ticker: str, horizon_days: int, indicators: Dict[str, Any]) -> None:
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO instrument_indicators (ticker, horizon_days, indicators_json) VALUES (?, ?, ?)",
|
||||
(ticker.upper(), horizon_days, json.dumps(indicators)),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_latest_instrument_indicators() -> List[Dict[str, Any]]:
|
||||
"""Most recent indicators row per ticker."""
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT i.* FROM instrument_indicators i
|
||||
INNER JOIN (
|
||||
SELECT ticker, MAX(computed_at) AS max_computed_at
|
||||
FROM instrument_indicators GROUP BY ticker
|
||||
) latest ON i.ticker = latest.ticker AND i.computed_at = latest.max_computed_at
|
||||
ORDER BY i.ticker"""
|
||||
).fetchall()
|
||||
conn.close()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["indicators"] = json.loads(d.pop("indicators_json") or "{}")
|
||||
except Exception:
|
||||
d["indicators"] = {}
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# ── System Logs ───────────────────────────────────────────────────────────────
|
||||
|
||||
def log_system_event(
|
||||
|
||||
@@ -495,14 +495,15 @@ def _to_event_dict(ev: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def bootstrap_ma_events() -> Dict[str, Any]:
|
||||
async def bootstrap_ma_events(force: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Main entrypoint. Detects MA ruptures, enriches with GPT, saves to DB.
|
||||
Idempotent: skips if DB already has > 100 events.
|
||||
Idempotent by default: skips if DB already has > 100 events. Pass force=True
|
||||
to bypass this guard (same convention as bootstrap_macro_events/bootstrap_eco_events).
|
||||
Returns {"detected": N, "saved": N, "skipped": N}.
|
||||
"""
|
||||
existing = count_market_events()
|
||||
if existing > 100:
|
||||
if existing > 100 and not force:
|
||||
logger.info(f"[MA] DB already has {existing} events — skipping bootstrap")
|
||||
return {"detected": 0, "saved": 0, "skipped": existing}
|
||||
|
||||
|
||||
41
backend/services/price_cache.py
Normal file
41
backend/services/price_cache.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Cycle Actions — standalone "refresh-price-data" action.
|
||||
|
||||
Downloads fresh OHLCV for every watchlist instrument and caches it in
|
||||
price_data_cache. Deliberately independent from the technical-indicators /
|
||||
wavelet code paths (which each do their own live yfinance fetch) — this is an
|
||||
inspection/refresh tool for decomposing a cycle, not a shared cache other
|
||||
steps depend on, so it can't regress the already-tested live cycle path.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def refresh_watchlist_price_data(period: str = "3mo") -> Dict[str, Any]:
|
||||
from services.database import get_instruments_watchlist, upsert_price_data
|
||||
from services.data_fetcher import get_historical
|
||||
|
||||
tickers = [w["ticker"] for w in get_instruments_watchlist()]
|
||||
per_ticker: Dict[str, int] = {}
|
||||
failed = []
|
||||
|
||||
for ticker in tickers:
|
||||
try:
|
||||
rows = get_historical(ticker, period=period, interval="1d")
|
||||
if not rows:
|
||||
failed.append(ticker)
|
||||
continue
|
||||
n = upsert_price_data(ticker, rows)
|
||||
per_ticker[ticker] = n
|
||||
except Exception as e:
|
||||
logger.warning(f"[PriceCache] Failed to refresh {ticker}: {e}")
|
||||
failed.append(ticker)
|
||||
|
||||
return {
|
||||
"tickers_refreshed": len(per_ticker),
|
||||
"rows_written": sum(per_ticker.values()),
|
||||
"per_ticker": per_ticker,
|
||||
"failed": failed,
|
||||
}
|
||||
@@ -175,3 +175,23 @@ def format_indicators_for_prompt(indicators: dict) -> str:
|
||||
if "error" in indicators:
|
||||
return ""
|
||||
return indicators.get("prompt_block", "")
|
||||
|
||||
|
||||
def compute_and_save_indicators(horizon_days: int = 45) -> dict:
|
||||
"""Cycle Actions — standalone "compute-indicators" action. compute_indicators()
|
||||
itself is pure (no persistence) — this loops the watchlist and persists each
|
||||
result into instrument_indicators (which nothing else reads from yet; this is
|
||||
an inspection snapshot, not a cache other steps depend on)."""
|
||||
from services.database import get_instruments_watchlist, save_instrument_indicators
|
||||
|
||||
tickers = [w["ticker"] for w in get_instruments_watchlist()]
|
||||
computed = 0
|
||||
failed = []
|
||||
for ticker in tickers:
|
||||
result = compute_indicators(ticker, horizon_days=horizon_days)
|
||||
if "error" in result:
|
||||
failed.append(ticker)
|
||||
continue
|
||||
save_instrument_indicators(ticker, horizon_days, result)
|
||||
computed += 1
|
||||
return {"tickers_computed": computed, "failed": failed, "horizon_days": horizon_days}
|
||||
|
||||
Reference in New Issue
Block a user