You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
826 lines
35 KiB
Python
826 lines
35 KiB
Python
"""CLI for paper trading with Alpaca API.
|
|
|
|
Usage:
|
|
fithia2 paper <command> [options]
|
|
|
|
Commands:
|
|
auto 자동 데몬 — 매일 파이프라인 + 매매를 스케줄에 맞게 자동 실행
|
|
start Create a new paper trading session
|
|
run Run daily processing for a session
|
|
run-close 장 마감 직전: same-day 이벤트 → MOC 매수
|
|
run-open 장 시작 직후: 전날 exit + after-close 이벤트 → 시장가 매수
|
|
run-all Run daily processing for all active sessions
|
|
monitor 장중 실시간 stop/target 모니터링
|
|
status Show session status
|
|
positions Show current positions (live from Alpaca)
|
|
trades Show trade history
|
|
equity Show equity curve
|
|
sessions List all sessions
|
|
pause Pause a session
|
|
resume Resume a paused session
|
|
close Close session (liquidates all positions)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
|
|
_console = Console(width=140)
|
|
|
|
# Default path for the paper trading SQLite DB
|
|
_DEFAULT_DB = os.environ.get("PAPER_TRADER_DB", "paper_trading.db")
|
|
|
|
|
|
def _get_state_manager(db_path: str):
|
|
from apps.paper_trader.state import StateManager
|
|
return StateManager(db_path)
|
|
|
|
|
|
def _get_broker():
|
|
from apps.paper_trader.alpaca_broker import AlpacaBroker
|
|
try:
|
|
return AlpacaBroker.from_env()
|
|
except (ValueError, ImportError) as exc:
|
|
_console.print(f"[red]ERROR: {exc}[/]")
|
|
sys.exit(1)
|
|
|
|
|
|
def _resolve_session(state, name_or_id: str):
|
|
session = state.get_session(name_or_id)
|
|
if session is None:
|
|
_console.print(f"[red]ERROR: Session not found: '{name_or_id}'[/]")
|
|
sys.exit(1)
|
|
return session
|
|
|
|
|
|
def _resolve_config_path(id_or_path: str) -> str:
|
|
"""Resolve an experiment ID or name to a config file path.
|
|
|
|
Accepts:
|
|
- Numeric ID: '228' → looks up name from index → 'configs/experiments/NAME.json'
|
|
- Experiment name: 'return_max_long_v6new.362' → 'configs/experiments/NAME.json'
|
|
- Existing file path: 'configs/experiments/foo.json' → unchanged
|
|
"""
|
|
if Path(id_or_path).exists():
|
|
return id_or_path
|
|
try:
|
|
from libs.backtest.experiments import resolve_experiment_name
|
|
name = resolve_experiment_name(id_or_path)
|
|
return f"configs/experiments/{name}.json"
|
|
except (KeyError, Exception):
|
|
# Fall back to treating as-is (original behaviour)
|
|
return id_or_path
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Commands
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def cmd_start(args: argparse.Namespace) -> None:
|
|
"""Create a new paper trading session."""
|
|
config_path = _resolve_config_path(args.config)
|
|
if not Path(config_path).exists():
|
|
_console.print(f"[red]ERROR: Config not found: {config_path}[/]")
|
|
sys.exit(1)
|
|
|
|
if (
|
|
getattr(args, "parking", None)
|
|
or getattr(args, "idle_alpha", None)
|
|
or getattr(args, "form4_sleeve", None)
|
|
or getattr(args, "ownership_sleeve", None)
|
|
):
|
|
from libs.backtest.domain import (
|
|
FORM4_CAPTURE_SLEEVE_PRESETS,
|
|
IDLE_ALPHA_SLEEVE_PRESETS,
|
|
OWNERSHIP_CAPTURE_SLEEVE_PRESETS,
|
|
PARKING_PRESETS,
|
|
)
|
|
if getattr(args, "parking", None) and args.parking not in PARKING_PRESETS:
|
|
_console.print(f"[red]ERROR: Unknown parking preset: {args.parking}[/]")
|
|
sys.exit(1)
|
|
if getattr(args, "idle_alpha", None) and args.idle_alpha not in IDLE_ALPHA_SLEEVE_PRESETS:
|
|
_console.print(f"[red]ERROR: Unknown idle alpha preset: {args.idle_alpha}[/]")
|
|
sys.exit(1)
|
|
if getattr(args, "form4_sleeve", None) and args.form4_sleeve not in FORM4_CAPTURE_SLEEVE_PRESETS:
|
|
_console.print(f"[red]ERROR: Unknown Form 4 sleeve preset: {args.form4_sleeve}[/]")
|
|
sys.exit(1)
|
|
if getattr(args, "ownership_sleeve", None) and args.ownership_sleeve not in OWNERSHIP_CAPTURE_SLEEVE_PRESETS:
|
|
_console.print(f"[red]ERROR: Unknown ownership sleeve preset: {args.ownership_sleeve}[/]")
|
|
sys.exit(1)
|
|
|
|
state = _get_state_manager(args.db)
|
|
|
|
# Check for duplicate name
|
|
existing = state.get_session(args.name)
|
|
if existing is not None:
|
|
_console.print(f"[red]ERROR: Session '{args.name}' already exists (id={existing.session_id}).[/]")
|
|
_console.print("Use a different name or close the existing session first.")
|
|
sys.exit(1)
|
|
|
|
session_id = state.create_session(
|
|
session_name=args.name,
|
|
config_path=config_path,
|
|
initial_equity=args.capital,
|
|
parking_preset=getattr(args, "parking", None),
|
|
idle_alpha_preset=getattr(args, "idle_alpha", None),
|
|
form4_sleeve_preset=getattr(args, "form4_sleeve", None),
|
|
ownership_sleeve_preset=getattr(args, "ownership_sleeve", None),
|
|
)
|
|
_console.print(f"[green]Session created:[/] {args.name} id={session_id} capital=${args.capital:,.0f}")
|
|
_console.print(f"[dim]Config: {config_path}[/]")
|
|
if getattr(args, "parking", None):
|
|
_console.print(f"[dim]Cash parking: {args.parking}[/]")
|
|
if getattr(args, "idle_alpha", None):
|
|
_console.print(f"[dim]Idle alpha: {args.idle_alpha}[/]")
|
|
if getattr(args, "form4_sleeve", None):
|
|
_console.print(f"[dim]Form 4 sleeve: {args.form4_sleeve}[/]")
|
|
if getattr(args, "ownership_sleeve", None):
|
|
_console.print(f"[dim]Ownership sleeve: {args.ownership_sleeve}[/]")
|
|
_console.print(f"\nRun daily processing with:\n fithia2 paper run --session {args.name}")
|
|
|
|
|
|
def cmd_run(args: argparse.Namespace) -> None:
|
|
"""Run daily processing for a specific session."""
|
|
state = _get_state_manager(args.db)
|
|
session = _resolve_session(state, args.session)
|
|
|
|
if session.status == "paused":
|
|
_console.print(f"[yellow]Session '{session.session_name}' is paused. Use 'resume' first.[/]")
|
|
sys.exit(1)
|
|
if session.status == "closed":
|
|
_console.print(f"[red]Session '{session.session_name}' is closed.[/]")
|
|
sys.exit(1)
|
|
|
|
broker = _get_broker()
|
|
|
|
from apps.paper_trader.event_detector import EventDetector
|
|
from apps.paper_trader.engine import PaperTradingEngine
|
|
from apps.paper_trader.reporter import print_run_summary
|
|
|
|
oracle_url = os.environ.get("ORACLE_URL") or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000")
|
|
db_dsn = os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "")
|
|
|
|
detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url)
|
|
engine = PaperTradingEngine(session=session, broker=broker, state=state, event_detector=detector)
|
|
|
|
target_date = None
|
|
if args.date:
|
|
import datetime as dt
|
|
try:
|
|
target_date = dt.date.fromisoformat(args.date)
|
|
except ValueError:
|
|
_console.print(f"[red]ERROR: Invalid date format: {args.date}. Use YYYY-MM-DD.[/]")
|
|
sys.exit(1)
|
|
|
|
summary = asyncio.run(engine.run_daily(target_date=target_date, force=getattr(args, "force", False)))
|
|
print_run_summary(summary)
|
|
|
|
|
|
def _make_engine(session, args):
|
|
broker = _get_broker()
|
|
from apps.paper_trader.event_detector import EventDetector
|
|
from apps.paper_trader.engine import PaperTradingEngine
|
|
from apps.backtester.run import load_manifest, resolve_config
|
|
from apps.paper_trader.backtest_sim import load_snapshot_store_for_session
|
|
oracle_url = os.environ.get("ORACLE_URL") or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000")
|
|
db_dsn = os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "")
|
|
detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url)
|
|
state = _get_state_manager(args.db)
|
|
manifest = load_manifest(session.config_path)
|
|
config = resolve_config(manifest)
|
|
snapshot_store = load_snapshot_store_for_session(config, oracle_url, db_dsn)
|
|
return PaperTradingEngine(
|
|
session=session, broker=broker, state=state, event_detector=detector,
|
|
snapshot_store=snapshot_store,
|
|
)
|
|
|
|
|
|
def cmd_run_close(args: argparse.Namespace) -> None:
|
|
"""장 마감 직전: same-day 이벤트 → MOC 매수."""
|
|
state = _get_state_manager(args.db)
|
|
session = _resolve_session(state, args.session)
|
|
|
|
if session.status != "active":
|
|
_console.print(f"[red]Session '{session.session_name}' is not active.[/]")
|
|
sys.exit(1)
|
|
|
|
import datetime as dt
|
|
target_date = None
|
|
if args.date:
|
|
try:
|
|
target_date = dt.date.fromisoformat(args.date)
|
|
except ValueError:
|
|
_console.print(f"[red]ERROR: Invalid date: {args.date}[/]")
|
|
sys.exit(1)
|
|
|
|
engine = _make_engine(session, args)
|
|
from apps.paper_trader.reporter import print_run_summary
|
|
summary = asyncio.run(engine.run_reaction_close(target_date=target_date, force=getattr(args, "force", False)))
|
|
print_run_summary(summary)
|
|
|
|
|
|
def cmd_run_open(args: argparse.Namespace) -> None:
|
|
"""장 시작 직후: 전날 exit 판단 + after-close 이벤트 → 시장가 매수."""
|
|
state = _get_state_manager(args.db)
|
|
session = _resolve_session(state, args.session)
|
|
|
|
if session.status != "active":
|
|
_console.print(f"[red]Session '{session.session_name}' is not active.[/]")
|
|
sys.exit(1)
|
|
|
|
import datetime as dt
|
|
target_date = None
|
|
if args.date:
|
|
try:
|
|
target_date = dt.date.fromisoformat(args.date)
|
|
except ValueError:
|
|
_console.print(f"[red]ERROR: Invalid date: {args.date}[/]")
|
|
sys.exit(1)
|
|
|
|
engine = _make_engine(session, args)
|
|
from apps.paper_trader.reporter import print_run_summary
|
|
summary = asyncio.run(engine.run_next_open(target_date=target_date, force=getattr(args, "force", False)))
|
|
print_run_summary(summary)
|
|
|
|
|
|
def cmd_monitor(args: argparse.Namespace) -> None:
|
|
"""장중 실시간 stop/target 모니터링 (Ctrl+C로 종료)."""
|
|
state = _get_state_manager(args.db)
|
|
session = _resolve_session(state, args.session)
|
|
|
|
if session.status != "active":
|
|
_console.print(f"[red]Session '{session.session_name}' is not active.[/]")
|
|
sys.exit(1)
|
|
|
|
_console.print(f"[cyan]Monitoring session '{session.session_name}' every {args.interval}s (Ctrl+C to stop)...[/]")
|
|
engine = _make_engine(session, args)
|
|
try:
|
|
asyncio.run(engine.run_monitor(interval_sec=args.interval))
|
|
except KeyboardInterrupt:
|
|
_console.print("\n[yellow]Monitor stopped.[/]")
|
|
|
|
|
|
def cmd_run_all(args: argparse.Namespace) -> None:
|
|
"""Run daily processing for all active sessions."""
|
|
state = _get_state_manager(args.db)
|
|
sessions = [s for s in state.list_sessions() if s.status == "active"]
|
|
|
|
if not sessions:
|
|
_console.print("[dim]No active sessions found.[/]")
|
|
return
|
|
|
|
broker = _get_broker()
|
|
|
|
from apps.paper_trader.event_detector import EventDetector
|
|
from apps.paper_trader.engine import PaperTradingEngine
|
|
from apps.paper_trader.reporter import print_run_summary
|
|
|
|
oracle_url = os.environ.get("ORACLE_URL") or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000")
|
|
db_dsn = os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "")
|
|
|
|
for session in sessions:
|
|
_console.print(f"\n[bold]Running session: {session.session_name}[/]")
|
|
detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url)
|
|
engine = PaperTradingEngine(session=session, broker=broker, state=state, event_detector=detector)
|
|
summary = asyncio.run(engine.run_daily())
|
|
print_run_summary(summary)
|
|
|
|
|
|
def _resolve_sessions(state, name_or_id: str | None) -> list:
|
|
"""Return a single session if specified, or all sessions if None."""
|
|
if name_or_id:
|
|
session = state.get_session(name_or_id)
|
|
if session is None:
|
|
_console.print(f"[red]ERROR: Session not found: '{name_or_id}'[/]")
|
|
sys.exit(1)
|
|
return [session]
|
|
sessions = state.list_sessions()
|
|
if not sessions:
|
|
_console.print("[dim]No sessions found.[/]")
|
|
sys.exit(0)
|
|
return sessions
|
|
|
|
|
|
def cmd_status(args: argparse.Namespace) -> None:
|
|
"""Show session status."""
|
|
state = _get_state_manager(args.db)
|
|
broker = _get_broker()
|
|
from apps.paper_trader.reporter import print_status
|
|
for session in _resolve_sessions(state, args.session):
|
|
print_status(session, broker, state)
|
|
|
|
|
|
def cmd_positions(args: argparse.Namespace) -> None:
|
|
"""Show current positions (live from Alpaca)."""
|
|
state = _get_state_manager(args.db)
|
|
broker = _get_broker()
|
|
from apps.paper_trader.reporter import print_positions
|
|
for session in _resolve_sessions(state, args.session):
|
|
print_positions(session, broker, state)
|
|
|
|
|
|
def cmd_trades(args: argparse.Namespace) -> None:
|
|
"""Show trade history."""
|
|
state = _get_state_manager(args.db)
|
|
from apps.paper_trader.reporter import print_trades
|
|
for session in _resolve_sessions(state, args.session):
|
|
print_trades(session, state, last=args.last)
|
|
|
|
|
|
def cmd_equity(args: argparse.Namespace) -> None:
|
|
"""Show equity curve."""
|
|
state = _get_state_manager(args.db)
|
|
from apps.paper_trader.reporter import print_equity
|
|
for session in _resolve_sessions(state, args.session):
|
|
print_equity(session, state)
|
|
|
|
|
|
def cmd_sessions(args: argparse.Namespace) -> None:
|
|
"""List all sessions."""
|
|
state = _get_state_manager(args.db)
|
|
sessions = state.list_sessions()
|
|
|
|
from apps.paper_trader.reporter import print_sessions
|
|
print_sessions(sessions)
|
|
|
|
|
|
def cmd_pause(args: argparse.Namespace) -> None:
|
|
"""Pause a session (stops daily processing)."""
|
|
state = _get_state_manager(args.db)
|
|
session = _resolve_session(state, args.session)
|
|
if session.status != "active":
|
|
_console.print(f"[yellow]Session '{session.session_name}' is already {session.status}.[/]")
|
|
return
|
|
state.set_session_status(session.session_id, "paused")
|
|
_console.print(f"[yellow]Session '{session.session_name}' paused.[/]")
|
|
|
|
|
|
def cmd_resume(args: argparse.Namespace) -> None:
|
|
"""Resume a paused session."""
|
|
state = _get_state_manager(args.db)
|
|
session = _resolve_session(state, args.session)
|
|
if session.status != "paused":
|
|
_console.print(f"[yellow]Session '{session.session_name}' is not paused (status={session.status}).[/]")
|
|
return
|
|
state.set_session_status(session.session_id, "active")
|
|
_console.print(f"[green]Session '{session.session_name}' resumed.[/]")
|
|
|
|
|
|
def _resolve_rank_configs(start: int, end: int) -> list[str]:
|
|
"""Load strategies ranked start..end from leaderboard by SQS score.
|
|
|
|
start/end are 1-based inclusive. e.g. (1, 5) = top 5, (20, 40) = rank 20-40.
|
|
"""
|
|
import json
|
|
|
|
registry_path = Path("journal/experiment_registry.json")
|
|
if not registry_path.exists():
|
|
_console.print("[red]ERROR: journal/experiment_registry.json not found. Run `fithia2 lb` first.[/]")
|
|
sys.exit(1)
|
|
|
|
registry = json.loads(registry_path.read_text())
|
|
ranked: list[str] = []
|
|
entries = sorted(
|
|
(
|
|
e for e in registry.get("entries", [])
|
|
if e.get("sqs_score") is not None
|
|
and not e.get("is_retired", False)
|
|
),
|
|
key=lambda e: e["sqs_score"],
|
|
reverse=True,
|
|
)
|
|
|
|
for e in entries:
|
|
if e.get("trade_count", 0) <= 0 or e.get("valid_trade_count", 0) <= 0:
|
|
continue
|
|
name = e["experiment_name"]
|
|
cfg_path = e.get("config_path") or f"configs/experiments/{name}.json"
|
|
if Path(cfg_path).exists():
|
|
ranked.append(cfg_path)
|
|
|
|
# 1-based inclusive slice
|
|
return ranked[start - 1 : end]
|
|
|
|
|
|
def cmd_backtest(args: argparse.Namespace) -> None:
|
|
"""Run historical backtest simulation using paper trading engine."""
|
|
import datetime as dt
|
|
|
|
configs = [_resolve_config_path(c) for c in (args.configs or [])]
|
|
if args.top:
|
|
configs = _resolve_rank_configs(1, args.top) + configs
|
|
if args.rank:
|
|
parts = args.rank.split("-")
|
|
if len(parts) == 1 and parts[0].isdigit():
|
|
n = int(parts[0])
|
|
configs = _resolve_rank_configs(n, n) + configs
|
|
elif len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
configs = _resolve_rank_configs(int(parts[0]), int(parts[1])) + configs
|
|
else:
|
|
_console.print("[red]ERROR: --rank format: N or START-END (e.g. 5 or 20-40)[/]")
|
|
sys.exit(1)
|
|
if not configs:
|
|
_console.print("[red]ERROR: Specify --config, --top, or --rank[/]")
|
|
sys.exit(1)
|
|
|
|
for cfg in configs:
|
|
if not Path(cfg).exists():
|
|
_console.print(f"[red]ERROR: Config not found: {cfg}[/]")
|
|
sys.exit(1)
|
|
|
|
if (
|
|
getattr(args, "parking", None)
|
|
or getattr(args, "idle_alpha", None)
|
|
or getattr(args, "form4_sleeve", None)
|
|
or getattr(args, "ownership_sleeve", None)
|
|
):
|
|
from libs.backtest.domain import (
|
|
FORM4_CAPTURE_SLEEVE_PRESETS,
|
|
IDLE_ALPHA_SLEEVE_PRESETS,
|
|
OWNERSHIP_CAPTURE_SLEEVE_PRESETS,
|
|
PARKING_PRESETS,
|
|
)
|
|
if getattr(args, "parking", None) and args.parking not in PARKING_PRESETS:
|
|
_console.print(f"[red]ERROR: Unknown parking preset: {args.parking}[/]")
|
|
sys.exit(1)
|
|
if getattr(args, "idle_alpha", None) and args.idle_alpha not in IDLE_ALPHA_SLEEVE_PRESETS:
|
|
_console.print(f"[red]ERROR: Unknown idle alpha preset: {args.idle_alpha}[/]")
|
|
sys.exit(1)
|
|
if getattr(args, "form4_sleeve", None) and args.form4_sleeve not in FORM4_CAPTURE_SLEEVE_PRESETS:
|
|
_console.print(f"[red]ERROR: Unknown Form 4 sleeve preset: {args.form4_sleeve}[/]")
|
|
sys.exit(1)
|
|
if getattr(args, "ownership_sleeve", None) and args.ownership_sleeve not in OWNERSHIP_CAPTURE_SLEEVE_PRESETS:
|
|
_console.print(f"[red]ERROR: Unknown ownership sleeve preset: {args.ownership_sleeve}[/]")
|
|
sys.exit(1)
|
|
|
|
import calendar
|
|
|
|
def _latest_backtest_date() -> dt.date:
|
|
"""Return today if market is closed (after 4 PM ET or non-trading day), else yesterday."""
|
|
from libs.common.time_utils import is_trading_day, to_eastern, utc_now
|
|
now_et = to_eastern(utc_now())
|
|
today = now_et.date()
|
|
if not is_trading_day(today) or now_et.hour >= 16:
|
|
return today
|
|
return today - dt.timedelta(days=1)
|
|
|
|
def _parse_date(val: str, is_end: bool = False) -> dt.date:
|
|
"""Parse YYYY-MM-DD, YYYY-MM, or YYYY. Clamp end dates to latest available."""
|
|
latest = _latest_backtest_date()
|
|
parts = val.split("-")
|
|
if len(parts) == 1 and len(val) == 4 and val.isdigit():
|
|
# YYYY
|
|
year = int(val)
|
|
if is_end:
|
|
return min(dt.date(year, 12, 31), latest)
|
|
return dt.date(year, 1, 1)
|
|
if len(parts) == 2:
|
|
# YYYY-MM
|
|
year, month = int(parts[0]), int(parts[1])
|
|
if is_end:
|
|
last_day = calendar.monthrange(year, month)[1]
|
|
return min(dt.date(year, month, last_day), latest)
|
|
return dt.date(year, month, 1)
|
|
return dt.date.fromisoformat(val)
|
|
|
|
# Resolve --year shorthand
|
|
if args.year:
|
|
if args.start or args.end:
|
|
_console.print("[red]ERROR: --year cannot be combined with --start/--end[/]")
|
|
sys.exit(1)
|
|
args.start = args.year
|
|
args.end = args.year
|
|
|
|
if not args.start:
|
|
_console.print("[red]ERROR: Specify --start (and optionally --end), or --year[/]")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
start_date = _parse_date(args.start)
|
|
end_date = _parse_date(args.end, is_end=True) if args.end else _latest_backtest_date()
|
|
except ValueError as exc:
|
|
_console.print(f"[red]ERROR: Invalid date: {exc}[/]")
|
|
sys.exit(1)
|
|
if getattr(args, "non_core_allocator_v2", False) and not getattr(args, "non_core_allocator_v2_mode", None):
|
|
args.non_core_allocator_v2_mode = "shadow"
|
|
|
|
oracle_url = os.environ.get("ORACLE_URL") or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000")
|
|
db_dsn = os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "")
|
|
|
|
if not db_dsn:
|
|
_console.print("[red]ERROR: POSTGRES_DSN (or DB_DSN) env var not set.[/]")
|
|
sys.exit(1)
|
|
|
|
_console.print(f"[bold cyan]Backtest:[/] {start_date} → {end_date} capital=${args.capital:,.0f}")
|
|
names = [Path(c).stem for c in configs]
|
|
_console.print(f"Strategies ({len(configs)}): {', '.join(names)}")
|
|
|
|
from apps.paper_trader.backtest_sim import run_backtest
|
|
from apps.paper_trader.reporter import print_backtest_results
|
|
|
|
results = run_backtest(
|
|
configs=configs,
|
|
capital=args.capital,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
db_dsn=db_dsn,
|
|
oracle_url=oracle_url,
|
|
console=_console,
|
|
parking_preset=getattr(args, 'parking', None),
|
|
idle_alpha_preset=getattr(args, 'idle_alpha', None),
|
|
form4_sleeve_preset=getattr(args, 'form4_sleeve', None),
|
|
ownership_sleeve_preset=getattr(args, 'ownership_sleeve', None),
|
|
risk_off_alpha_sleeve_preset=getattr(args, 'risk_off_sleeve', None),
|
|
non_core_allocator_v2_mode=getattr(args, 'non_core_allocator_v2_mode', None),
|
|
snapshot_id_override=getattr(args, 'snapshot_id', None),
|
|
auto_refresh=not getattr(args, 'no_refresh', False),
|
|
)
|
|
print_backtest_results(results, output_dir=args.output, show_trades=not args.no_trades)
|
|
|
|
|
|
def cmd_auto(args: argparse.Namespace) -> None:
|
|
"""자동 데몬 — Phoenix(MST) 기준 ET 장 스케줄에 맞게 파이프라인 + 매매 자동 실행."""
|
|
from apps.paper_trader.auto import run_auto
|
|
run_auto(
|
|
sessions=args.session or [],
|
|
db=args.db,
|
|
dry_run=args.dry_run,
|
|
)
|
|
|
|
|
|
def cmd_close(args: argparse.Namespace) -> None:
|
|
"""Close a session and liquidate all positions."""
|
|
state = _get_state_manager(args.db)
|
|
session = _resolve_session(state, args.session)
|
|
|
|
if session.status == "closed":
|
|
_console.print(f"[dim]Session '{session.session_name}' is already closed.[/]")
|
|
return
|
|
|
|
broker = _get_broker()
|
|
|
|
# Confirm
|
|
if not args.yes:
|
|
positions = broker.list_positions()
|
|
if positions:
|
|
_console.print(f"[yellow]This will liquidate {len(positions)} open position(s):[/]")
|
|
for p in positions:
|
|
_console.print(f" {p.symbol}: {p.qty} shares")
|
|
confirm = input(f"Close session '{session.session_name}'? [y/N] ")
|
|
if confirm.lower() != "y":
|
|
_console.print("Cancelled.")
|
|
return
|
|
|
|
# Liquidate
|
|
try:
|
|
orders = broker.close_all_positions()
|
|
_console.print(f"[yellow]Liquidated {len(orders)} position(s).[/]")
|
|
except Exception as exc:
|
|
_console.print(f"[red]WARNING: Failed to close positions: {exc}[/]")
|
|
|
|
# Mark all open strategy states as closed
|
|
for ss in state.get_open_strategy_states(session.session_id):
|
|
state.close_strategy_state(session.session_id, ss.symbol)
|
|
|
|
# Delete session and all related data
|
|
state.delete_session(session.session_id)
|
|
_console.print(f"[red]Session '{session.session_name}' closed and deleted.[/]")
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Main
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _print_help() -> None:
|
|
from rich import box as rbox
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
|
|
_console.print()
|
|
_console.print(Panel(
|
|
"[bold cyan]fithia2 paper-trader[/] — Alpaca Paper Trading CLI\n"
|
|
"[dim]전략 백테스트 로직으로 판단, Alpaca Paper Trading API로 주문 실행[/]",
|
|
border_style="cyan",
|
|
padding=(0, 2),
|
|
))
|
|
|
|
tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 2))
|
|
tbl.add_column("Command", style="bold green", no_wrap=True)
|
|
tbl.add_column("Description")
|
|
tbl.add_column("Key Options", style="dim")
|
|
|
|
tbl.add_row("[bold cyan]backtest[/]", "과거 기간 시뮬레이션 (복수 전략 비교)", "--config PATH | --top N --year YYYY | --start DATE --end DATE [--capital N] [--parking P] [--idle-alpha P] [--form4-sleeve P] [--ownership-sleeve P]")
|
|
tbl.add_row("[bold cyan]auto[/]", "자동 데몬 — 스케줄에 맞게 파이프라인+매매 자동 실행", "[--session NAME] [--dry-run]")
|
|
tbl.add_row("", "", "")
|
|
tbl.add_row("start", "새 세션 생성", "--config PATH --capital FLOAT --name STR [--parking P] [--idle-alpha P] [--form4-sleeve P] [--ownership-sleeve P]")
|
|
tbl.add_row("run", "일일 처리 실행 (특정 세션)", "--session NAME [--date YYYY-MM-DD]")
|
|
tbl.add_row("run-close", "장 마감 직전: same-day 이벤트 → MOC 매수", "--session NAME [--date]")
|
|
tbl.add_row("run-open", "장 시작 직후: exit + after-close 신규진입", "--session NAME [--date]")
|
|
tbl.add_row("run-all", "전체 활성 세션 일일 처리", "")
|
|
tbl.add_row("monitor", "장중 실시간 stop/target 모니터링", "--session NAME [--interval N]")
|
|
tbl.add_row("", "", "")
|
|
tbl.add_row("status", "세션 상태 조회", "--session NAME")
|
|
tbl.add_row("positions", "보유 포지션 조회 (Alpaca 실시간)", "--session NAME")
|
|
tbl.add_row("trades", "거래 내역", "--session NAME [--last N]")
|
|
tbl.add_row("equity", "Equity curve", "--session NAME")
|
|
tbl.add_row("sessions", "세션 목록", "")
|
|
tbl.add_row("pause", "세션 일시정지", "--session NAME")
|
|
tbl.add_row("resume", "세션 재개", "--session NAME")
|
|
tbl.add_row("close", "세션 종료 (전 포지션 청산)", "--session NAME [--yes]")
|
|
|
|
_console.print(tbl)
|
|
_console.print(
|
|
" [dim]공통 옵션:[/] [bold]--db[/] [dim](기본: paper_trading.db)[/]\n"
|
|
" [dim]환경변수:[/] ALPACA_API_KEY ALPACA_SECRET_KEY ALPACA_PAPER=true "
|
|
"ORACLE_URL DB_DSN\n"
|
|
)
|
|
_console.print(" [bold]예시[/]")
|
|
_console.print(" [green]fithia2 paper auto --session my_session[/] [dim]← 추천: 데몬으로 모든 것을 자동 처리[/]")
|
|
_console.print(" [green]fithia2 paper start --config configs/experiments/return_max_long_v504.json --name my_session[/]")
|
|
_console.print(" [green]fithia2 paper positions --session my_session[/]")
|
|
_console.print(" [green]fithia2 paper trades --session my_session --last 20[/]")
|
|
_console.print()
|
|
|
|
|
|
def main() -> None:
|
|
# Auto-load .env file (python-dotenv)
|
|
try:
|
|
from dotenv import load_dotenv
|
|
load_dotenv()
|
|
except ImportError:
|
|
pass
|
|
|
|
if len(sys.argv) == 1:
|
|
_print_help()
|
|
sys.exit(0)
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="fithia2 Paper Trader — Alpaca Paper Trading CLI",
|
|
add_help=True,
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
db_kwargs = {"default": _DEFAULT_DB, "help": f"SQLite DB path (default: {_DEFAULT_DB})"}
|
|
|
|
# backtest
|
|
p = sub.add_parser("backtest", help="Run historical backtest simulation using paper trading engine")
|
|
p.add_argument("--config", "-c", action="append",
|
|
dest="configs", metavar="PATH",
|
|
help="Config path (repeat for multiple strategies)")
|
|
p.add_argument("--top", "-t", type=int, default=None, metavar="N",
|
|
help="Use top N strategies from leaderboard (by SQS score)")
|
|
p.add_argument("--rank", default=None, metavar="START-END",
|
|
help="Use strategies ranked START to END (e.g. 20-40)")
|
|
p.add_argument("--capital", "-k", type=float, default=10000.0,
|
|
help="Per-session capital (default: 10000)")
|
|
p.add_argument("--start", default=None, metavar="YYYY[-MM-DD]",
|
|
help="Backtest start date (YYYY-MM-DD or YYYY)")
|
|
p.add_argument("--end", default=None, metavar="YYYY[-MM-DD]",
|
|
help="Backtest end date (YYYY-MM-DD or YYYY)")
|
|
p.add_argument("--year", "-y", default=None, metavar="YYYY",
|
|
help="Shorthand for --start YYYY --end YYYY")
|
|
p.add_argument("--output", "-o", default=None,
|
|
help="Directory to save results CSV (optional)")
|
|
p.add_argument("--no-trades", action="store_true", default=False,
|
|
help="Hide per-strategy trade log")
|
|
p.add_argument("--parking", default=None, metavar="PRESET",
|
|
help="Apply cash parking preset (e.g. vm_24_m20, vol_20_24, sgov). Overrides config.")
|
|
p.add_argument("--idle-alpha", default=None, metavar="PRESET",
|
|
help="Apply idle alpha sleeve preset (e.g. micro_event_alpha). Overrides config.")
|
|
p.add_argument("--form4-sleeve", default=None, metavar="PRESET",
|
|
help="Apply Form 4 sleeve preset (e.g. reserve_form4_cluster_patient). Overrides config.")
|
|
p.add_argument("--ownership-sleeve", default=None, metavar="PRESET",
|
|
help="Apply 13D/13G ownership sleeve preset (e.g. ownership_13d_raise_reserve_plus_strict). Overrides config.")
|
|
p.add_argument("--risk-off-sleeve", default=None, metavar="PRESET",
|
|
help="Apply risk-off alpha sleeve preset. Overrides config.")
|
|
p.add_argument("--non-core-allocator-v2", action="store_true", default=False,
|
|
help="Enable non-core allocator v2.")
|
|
p.add_argument("--non-core-allocator-v2-mode", default=None, choices=["shadow", "live"],
|
|
help="Run non-core allocator v2 in shadow or live mode.")
|
|
p.add_argument("--snapshot-id", default=None, metavar="SNAPSHOT_ID",
|
|
help="Override dataset_snapshot_id (e.g. midlarge-liquid-long-v1-oot-2020-2021)")
|
|
p.add_argument("--no-refresh", action="store_true", default=False,
|
|
help="Skip automatic snapshot refresh (use existing data as-is)")
|
|
|
|
# auto
|
|
p = sub.add_parser("auto", help="자동 데몬 — ET 장 스케줄에 맞게 파이프라인+매매 자동 실행")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", nargs="*", default=[], metavar="NAME",
|
|
help="Session name(s). Default: all active sessions.")
|
|
p.add_argument("--dry-run", action="store_true",
|
|
help="Print commands without executing.")
|
|
|
|
# start
|
|
p = sub.add_parser("start", help="Create a new paper trading session")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--config", "-c", required=True, help="Path to experiment manifest or config JSON")
|
|
p.add_argument("--capital", "-k", type=float, default=10000.0, help="Initial capital (default: 10000)")
|
|
p.add_argument("--name", "-n", required=True, help="Session name (unique)")
|
|
p.add_argument("--parking", default=None, metavar="PRESET", help="Cash parking preset name")
|
|
p.add_argument("--idle-alpha", default=None, metavar="PRESET", help="Idle alpha sleeve preset name")
|
|
p.add_argument("--form4-sleeve", default=None, metavar="PRESET", help="Form 4 sleeve preset name")
|
|
p.add_argument("--ownership-sleeve", default=None, metavar="PRESET", help="13D/13G ownership sleeve preset name")
|
|
|
|
# run
|
|
p = sub.add_parser("run", help="Run daily processing for a session")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", required=True, help="Session name or ID")
|
|
p.add_argument("--date", "-d", help="Target date YYYY-MM-DD (default: today)")
|
|
p.add_argument("--force", "-f", action="store_true", help="Re-run even if date already processed")
|
|
|
|
# run-close
|
|
p = sub.add_parser("run-close", help="장 마감 직전: same-day 이벤트 → MOC 매수")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", required=True, help="Session name or ID")
|
|
p.add_argument("--date", "-d", help="Target date YYYY-MM-DD (default: today)")
|
|
p.add_argument("--force", "-f", action="store_true", help="Re-run even if already processed")
|
|
|
|
# run-open
|
|
p = sub.add_parser("run-open", help="장 시작 직후: 전날 exit + after-close 이벤트 → 시장가 매수")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", required=True, help="Session name or ID")
|
|
p.add_argument("--date", "-d", help="Target date YYYY-MM-DD (default: today)")
|
|
p.add_argument("--force", "-f", action="store_true", help="Re-run even if already processed")
|
|
|
|
# monitor
|
|
p = sub.add_parser("monitor", help="장중 실시간 stop/target 모니터링")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", required=True, help="Session name or ID")
|
|
p.add_argument("--interval", "-i", type=int, default=60, help="Check interval in seconds (default: 60)")
|
|
|
|
# run-all
|
|
p = sub.add_parser("run-all", help="Run daily processing for all active sessions")
|
|
p.add_argument("--db", **db_kwargs)
|
|
|
|
# status
|
|
p = sub.add_parser("status", help="Show session status")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", default=None, help="Session name or ID (omit for all)")
|
|
|
|
# positions
|
|
p = sub.add_parser("positions", help="Show current positions")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", default=None, help="Session name or ID (omit for all)")
|
|
|
|
# trades
|
|
p = sub.add_parser("trades", help="Show trade history")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", default=None, help="Session name or ID (omit for all)")
|
|
p.add_argument("--last", "-n", type=int, default=None, help="Show last N trades")
|
|
|
|
# equity
|
|
p = sub.add_parser("equity", help="Show equity curve")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", default=None, help="Session name or ID (omit for all)")
|
|
|
|
# sessions
|
|
p = sub.add_parser("sessions", help="List all sessions")
|
|
p.add_argument("--db", **db_kwargs)
|
|
|
|
# pause
|
|
p = sub.add_parser("pause", help="Pause a session")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", required=True, help="Session name or ID")
|
|
|
|
# resume
|
|
p = sub.add_parser("resume", help="Resume a paused session")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", required=True, help="Session name or ID")
|
|
|
|
# close
|
|
p = sub.add_parser("close", help="Close session (liquidates all positions)")
|
|
p.add_argument("--db", **db_kwargs)
|
|
p.add_argument("--session", "-s", required=True, help="Session name or ID")
|
|
p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
|
|
|
|
args = parser.parse_args()
|
|
|
|
dispatch = {
|
|
"backtest": cmd_backtest,
|
|
"auto": cmd_auto,
|
|
"start": cmd_start,
|
|
"run": cmd_run,
|
|
"run-close": cmd_run_close,
|
|
"run-open": cmd_run_open,
|
|
"monitor": cmd_monitor,
|
|
"run-all": cmd_run_all,
|
|
"status": cmd_status,
|
|
"positions": cmd_positions,
|
|
"trades": cmd_trades,
|
|
"equity": cmd_equity,
|
|
"sessions": cmd_sessions,
|
|
"pause": cmd_pause,
|
|
"resume": cmd_resume,
|
|
"close": cmd_close,
|
|
}
|
|
dispatch[args.command](args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|