Compare commits

...

16 Commits

Author SHA1 Message Date
kjh2064 aa61465ce0 refactor(dotnet): centralize domain numeric guards
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 15s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m4s
2026-07-13 00:48:21 +09:00
kjh2064 bccefed35e refactor(dotnet): separate collection read model service
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 16s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m3s
2026-07-13 00:44:43 +09:00
kjh2064 d610ecb57c feat(dotnet): add read model contract harness
CI Workflow Lint / validate-ci-workflow-lint (push) Failing after 13s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 23s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m11s
2026-07-13 00:41:30 +09:00
kjh2064 c852ad49cf refactor(dotnet): externalize scheduler definitions
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 16s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-13 00:39:59 +09:00
kjh2064 3fbb5ea2bf feat(dotnet): add domain parity backlog harness
CI Workflow Lint / validate-ci-workflow-lint (push) Failing after 14s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 23s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m10s
2026-07-13 00:36:41 +09:00
kjh2064 a45961928e feat(dotnet): add cicd chain contract harness
CI Workflow Lint / validate-ci-workflow-lint (push) Failing after 13s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 24s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-13 00:34:45 +09:00
kjh2064 736951526b feat(dotnet): add idempotency contract harness
CI Workflow Lint / validate-ci-workflow-lint (push) Failing after 14s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 21s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-13 00:32:48 +09:00
kjh2064 ed137c2574 feat(dotnet): add normalization contract harness
CI Workflow Lint / validate-ci-workflow-lint (push) Failing after 14s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 20s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-13 00:31:13 +09:00
kjh2064 b694a101d1 feat(dotnet): add scheduler contract harness
CI Workflow Lint / validate-ci-workflow-lint (push) Failing after 13s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 20s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m6s
2026-07-13 00:27:51 +09:00
kjh2064 14ced733f2 feat(dotnet): add provenance contract harness
CI Workflow Lint / validate-ci-workflow-lint (push) Failing after 14s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 20s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m6s
2026-07-13 00:24:44 +09:00
kjh2064 6475ecd3b0 refactor(dotnet): normalize factor input dates
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 19s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m6s
2026-07-13 00:20:46 +09:00
kjh2064 29929d76d3 test(dotnet): cover factor calculator input ordering
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 18s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-13 00:19:11 +09:00
kjh2064 6772a86081 refactor(dotnet): simplify factor calculator guards
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 25s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-13 00:18:05 +09:00
kjh2064 6ff40c8ea3 feat(dotnet): add factor computation audit service
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 19s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m5s
2026-07-13 00:15:55 +09:00
kjh2064 6f252162ef refactor(dotnet): add collection audit trail
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 23s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-13 00:14:05 +09:00
kjh2064 ee4ae5583d refactor(dotnet): add scheduler audit trail
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 16s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m0s
2026-07-13 00:11:52 +09:00
44 changed files with 1877 additions and 116 deletions
+21
View File
@@ -175,6 +175,27 @@ jobs:
- name: Validate Dotnet Parity Contract
run: python3 tools/validate_dotnet_parity_contract_v1.py
- name: Validate Dotnet Provenance Contract
run: python3 tools/validate_dotnet_provenance_contract_v1.py
- name: Validate Dotnet Scheduler Contract
run: python3 tools/validate_dotnet_scheduler_contract_v1.py
- name: Validate Dotnet Normalization Contract
run: python3 tools/validate_dotnet_normalization_contract_v1.py
- name: Validate Dotnet Idempotency Contract
run: python3 tools/validate_dotnet_idempotency_contract_v1.py
- name: Validate Dotnet CICD Chain Contract
run: python3 tools/validate_dotnet_cicd_chain_contract_v1.py
- name: Validate Dotnet Domain Parity Backlog
run: python3 tools/validate_dotnet_domain_parity_backlog_v1.py
- name: Validate Dotnet Read Model Contract
run: python3 tools/validate_dotnet_read_model_contract_v1.py
- name: Build Calibration Priority Backlog
+14
View File
@@ -100,9 +100,23 @@
- `docs/WBS_10_DOTNET_MIGRATION_INVENTORY.yaml`: WBS-10 전환 우선순위용 실행 경로 인벤토리.
- `docs/WBS_10_DOTNET_MIGRATION_EXECUTION_PLAN.yaml`: WBS-10 착수용 실행 분해 계획.
- `docs/WBS_10_DOTNET_PARITY_CONTRACT.yaml`: WBS-10 핵심 계산기 parity 계약.
- `docs/WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml`: WBS-10 provenance payload 표준 계약.
- `docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml`: WBS-10 scheduler state machine 계약.
- `docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml`: WBS-10 normalization/read model 계약.
- `docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml`: WBS-10 idempotency/lock 계약.
- `docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml`: WBS-10 CI/CD 순차 게이트 계약.
- `docs/WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml`: WBS-10 domain parity backlog contract.
- `docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml`: WBS-10 read model contract.
- `tools/validate_dotnet_migration_roadmap_v1.py`: WBS-10 상세 로드맵 YAML validator.
- `tools/validate_dotnet_migration_execution_plan_v1.py`: WBS-10 실행 분해 계획 validator.
- `tools/validate_dotnet_parity_contract_v1.py`: WBS-10 parity 계약 validator.
- `tools/validate_dotnet_provenance_contract_v1.py`: WBS-10 provenance 계약 validator.
- `tools/validate_dotnet_scheduler_contract_v1.py`: WBS-10 scheduler 계약 validator.
- `tools/validate_dotnet_normalization_contract_v1.py`: WBS-10 normalization 계약 validator.
- `tools/validate_dotnet_idempotency_contract_v1.py`: WBS-10 idempotency 계약 validator.
- `tools/validate_dotnet_cicd_chain_contract_v1.py`: WBS-10 CI/CD chain 계약 validator.
- `tools/validate_dotnet_domain_parity_backlog_v1.py`: WBS-10 domain parity backlog validator.
- `tools/validate_dotnet_read_model_contract_v1.py`: WBS-10 read model validator.
- `Temp/snapshot_admin_approval_packet_v1.json`: snapshot admin approval packet export.
- `Temp/snapshot_admin_approval_packet_v1.md`: snapshot admin approval packet summary.
- `Temp/`: 실행 결과와 캐시. 라우팅 대상은 아니며 runtime consumer만 읽는다.
+7
View File
@@ -1469,6 +1469,13 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행)
> 실행 분해 계획: [WBS_10_DOTNET_MIGRATION_EXECUTION_PLAN.yaml](./WBS_10_DOTNET_MIGRATION_EXECUTION_PLAN.yaml)
> 실행 분해 검증기: `tools/validate_dotnet_migration_execution_plan_v1.py`
> parity 계약: [WBS_10_DOTNET_PARITY_CONTRACT.yaml](./WBS_10_DOTNET_PARITY_CONTRACT.yaml)
> provenance 계약: [WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml](./WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml)
> scheduler contract: [WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml](./WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml)
> normalization contract: [WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml](./WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml)
> idempotency contract: [WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml](./WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml)
> ci/cd chain contract: [WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml](./WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml)
> domain parity backlog: [WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml](./WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml)
> read model contract: [WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml](./WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml)
> 현황 진단(2026-06-26): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준.
> Domain 계산기 6개·데이터 모델 8개·KIS/Naver/Yahoo 클라이언트·PostgreSQL 마이그레이션·Razor Pages 어드민 대시보드 기본 구현 완료.
@@ -0,0 +1,48 @@
formula_id: WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1
owner: QuantEngine
status: draft
goal: "CI, prepare-release, deploy-prod 순차 게이트를 고정한다."
workflows:
ci:
file: .gitea/workflows/ci.yml
name: "Validators (Pushes and Pull Requests)"
triggers:
- push: main
- pull_request: main
role: "upstream validator"
prepare_release:
file: .gitea/workflows/prepare-release.yml
name: "Prepare Release"
triggers:
- workflow_run: Validators (Pushes and Pull Requests)
- workflow_dispatch
role: "release builder"
upstream_gate: "Validators (Pushes and Pull Requests) success"
deploy_prod:
file: .gitea/workflows/deploy-prod.yml
name: "Deploy to Production"
triggers:
- workflow_run: Prepare Release
- workflow_dispatch
role: "production deployer"
upstream_gate: "Prepare Release success"
dependency_chain:
- "Validators (Pushes and Pull Requests) -> Prepare Release -> Deploy to Production"
required_guards:
- "prepare-release는 Validators 성공 없이는 실행 금지"
- "deploy-prod는 Prepare Release 성공 없이는 실행 금지"
- "deploy-prod는 upstream CI SHA를 release tag와 대조"
- "모든 단계는 concurrency group을 사용해 동일 SHA 중복 실행을 차단"
health_checks:
- "upstream workflow conclusion == success"
- "release tag sha matches workflow_run head_sha"
- "artifact 존재 확인"
- "SSH/Gitea secret 존재 확인"
notes:
- "순차 게이트는 workflow_run 연결과 검증 스텝 둘 다 필요하다."
- "병렬 실행은 금지된다."
@@ -0,0 +1,50 @@
formula_id: WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG_V1
owner: QuantEngine
status: draft
goal: "핵심 계산기 parity 대상과 우선순위를 고정한다."
coverage_min: 1.0
tolerance_policy:
numeric_default: 0
text_default: exact
factor_calculator_numeric: 0.000001
exit_stop_price_numeric: 0.0001
parity_targets:
- target_id: formula_engine_timing
source: src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs
priority: 1
reason: "timing decision은 downstream routing의 선행 게이트"
- target_id: formula_engine_sell
source: src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs
priority: 2
reason: "sell ratio/action은 실행 표 생성의 핵심"
- target_id: formula_engine_final
source: src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs
priority: 3
reason: "final decision은 보고/배포의 최종 산출"
- target_id: exit_stop_price
source: src/dotnet/QuantEngine.Core/Domain/ExitDecisions.cs
priority: 4
reason: "손절가 절대값 일치가 필요"
- target_id: exit_stop_ladder
source: src/dotnet/QuantEngine.Core/Domain/ExitDecisions.cs
priority: 5
reason: "워터폴 선형 처리 검증"
- target_id: exit_heat_thresholds
source: src/dotnet/QuantEngine.Core/Domain/ExitDecisions.cs
priority: 6
reason: "동적 열감 임계값 일치"
- target_id: factor_calculator
source: src/dotnet/QuantEngine.Core/Domain/FactorCalculator.cs
priority: 7
reason: "정규화된 입력 순서에서 deterministic output 보장"
coverage_rule:
- "priority 1..7 모두 존재해야 한다"
- "tolerance_policy는 숫자/텍스트 기본값을 정의해야 한다"
- "새 parity target은 reference fixture와 함께만 추가한다"
notes:
- "parity는 기능 추가가 아니라 회귀 차단 장치다."
- "수치 재계산은 reference fixture 외에서 하지 않는다."
@@ -0,0 +1,53 @@
formula_id: WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1
owner: QuantEngine
status: draft
goal: "중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다."
lock_domain:
canonical_table: quantengine.workspace_lock
fields:
- domain
- target_ref
- locked_by
- reason
- locked_at
invariant:
- "같은 domain + target_ref 조합은 동시에 하나만 존재"
- "잠금 해제는 동일 domain + target_ref 로만 수행"
- "잠금 없는 실행은 retryable 작업으로 취급하지 않는다"
idempotency_key:
required: true
pattern: "{job_id}:{resource_key}:{run_scope}"
scope_examples:
- "daily-collection:collection:yyyyMMdd"
- "hourly-price-update:ticker:yyyyMMddHH"
- "weekly-report:report:yyyy-'W'ww"
- "monthly-optimization:optimization:yyyy-MM"
lease_policy:
required: true
fields:
- lease_owner
- timeout_policy
- retry_policy
retry_policy:
max_attempts: 3
backoff: exponential
retryable_errors:
- transient network failure
- upstream timeout
- deadlock detected
non_retryable_errors:
- validation failure
- contract failure
- missing configuration
duplicate_execution_guards:
- "동일 job_id/resource_key/run_scope 중복 호출 금지"
- "동일 lock_domain이 점유 중이면 새 실행은 blocked"
- "중복 실행이 발생하면 audit는 남기되 write path는 재진입 금지"
notes:
- "idempotency는 hash 추정이 아니라 명시된 key 조합만 사용한다."
- "lock은 수동 승인 워크플로와 동일한 canonical table을 사용한다."
@@ -0,0 +1,85 @@
formula_id: WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1
owner: QuantEngine
status: draft
goal: "쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다."
canonical_write_path:
schema: engine_history
tables:
- source_observation
- factor_definition
- factor_observation
- decision_event
- decision_factor_evidence
- outcome_evaluation
invariant:
- "source_observation은 원천 관측 1건당 1행"
- "factor_definition은 (factor_id, factor_version) 단일 원장"
- "factor_observation은 observation_id를 반드시 참조"
- "decision_event는 decision_key로 단일 식별"
- "outcome_evaluation은 decision_id + horizon_days 조합으로 단일 식별"
canonical_read_path:
view: engine_history.training_example_v1
purpose: "모델 학습/캘리브레이션용 역정규화 projection"
consumers:
- model_training
- calibration_jobs
- diagnostics
forbidden_patterns:
- "읽기 모델을 쓰기 원장으로 사용"
- "원천 payload를 읽기 projection에 중복 저장"
- "직렬 UI 조회를 위해 원장 테이블을 직접 조인해 장기 유지"
expected_fields:
normalized_tables:
source_observation:
- observation_id
- observed_at
- instrument_id
- source_name
- source_version
- payload
- provenance
factor_observation:
- factor_observation_id
- observation_id
- factor_id
- factor_version
- observed_at
- numeric_value
- text_value
- gate
- provenance
decision_event:
- decision_id
- decision_key
- decided_at
- instrument_id
- action
- gate
- score
- source_version
- trace
- provenance
denormalized_view:
- decision_id
- decision_key
- decided_at
- instrument_id
- action
- decision_gate
- score
- source_version
- horizon_days
- realized_return
- benchmark_return
- excess_return
- outcome_class
- evaluation_gate
- factor_features
notes:
- "정규화는 쓰기 중복 제거와 provenance 보존이 목적이다."
- "역정규화는 학습/진단 편의용 projection으로만 허용한다."
@@ -0,0 +1,67 @@
formula_id: WBS_10_DOTNET_PROVENANCE_CONTRACT_V1
owner: QuantEngine
status: draft
goal: "결정/팩터/수집 provenance payload를 표준화한다."
payloads:
- payload_id: factor_evidence
source: src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs
required_fields:
- FactorObservationId
- FactorId
- FactorVersion
- ObservedAt
- Gate
- Role
- SourceName
- PayloadJson
- ProvenanceJson
nullable_fields:
- NumericValue
- TextValue
pass_condition: "factor evidence payload가 누락 없이 기록됨"
- payload_id: decision_event
source: src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs
required_fields:
- decisionKey
- decidedAt
- instrumentId
- action
- gate
- sourceVersion
nullable_fields:
- score
- trace
- provenance
pass_condition: "decision event payload가 normalized store에 기록됨"
- payload_id: collection_audit
source: src/dotnet/QuantEngine.Application/Models/CollectionExecutionAudit.cs
required_fields:
- RunId
- State
- StartedAt
- SuccessCount
- ErrorCount
nullable_fields:
- FinishedAt
- Message
pass_condition: "collection audit payload가 append-only JSONL에 기록됨"
- payload_id: scheduler_audit
source: src/dotnet/QuantEngine.Web/Services/SchedulerModels.cs
required_fields:
- JobId
- RunId
- State
- StartedAt
nullable_fields:
- Reason
- FinishedAt
- ResourceKey
pass_condition: "scheduler audit payload가 append-only JSONL에 기록됨"
notes:
- "provenance payload는 구조를 표준화하되, 숫자 계산은 하지 않는다."
- "LLM은 payload value를 재계산하지 않는다."
@@ -0,0 +1,54 @@
formula_id: WBS_10_DOTNET_READ_MODEL_CONTRACT_V1
owner: QuantEngine
status: draft
goal: "운영 화면과 조회 API의 read model 경계를 분리한다."
read_models:
- model_id: dashboard_summary
purpose: "운영 대시보드 상태"
source: QuantEngine.Infrastructure.Repositories.CollectionRepository
consumers:
- src/dotnet/QuantEngine.Web/Pages/Admin/Dashboard/Index.cshtml.cs
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
fields:
- LastRunId
- LastRunAt
- SuccessCount
- ErrorCount
- SnapshotCount
staleness_budget: "5m"
- model_id: collection_runs
purpose: "최근 수집 실행 이력"
source: QuantEngine.Infrastructure.Repositories.CollectionRepository
consumers:
- src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Index.cshtml.cs
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
fields:
- RunId
- State
- StartedAt
- FinishedAt
- SuccessCount
- ErrorCount
staleness_budget: "5m"
- model_id: price_history_summary
purpose: "가격 히스토리 요약"
source: QuantEngine.Infrastructure.Repositories.CollectionRepository
consumers:
- src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Index.cshtml.cs
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
fields:
- Ticker
- Count
- FirstDate
- LastDate
staleness_budget: "15m"
rules:
- "read model은 조회 전용이어야 한다."
- "운영 화면은 직접 원장 테이블을 조립하지 않는다."
- "쓰기 로직은 read model에 의존하지 않는다."
- "staleness_budget이 명시되지 않은 조회는 금지한다."
notes:
- "의도된 역정규화는 허용하되, 원장과 동일 테이블로 재사용하지 않는다."
@@ -0,0 +1,60 @@
formula_id: WBS_10_DOTNET_SCHEDULER_CONTRACT_V1
owner: QuantEngine
status: draft
goal: "스케줄러 상태 전이, 의존성, 재시도, 감사 추적을 표준화한다."
state_machine:
states:
- pending
- running
- succeeded
- failed
- retrying
- blocked
allowed_transitions:
pending: [running, blocked]
running: [succeeded, failed, retrying, blocked]
failed: [retrying, blocked]
retrying: [running, failed, blocked]
succeeded: []
blocked: []
job_definitions:
- job_id: daily-collection
cron: "0 9 * * *"
lease_owner: collection
timeout_policy: "2h"
dependency: gather-trading-data
- job_id: hourly-price-update
cron: "0 9,11,13,15 * * 1-5"
lease_owner: price-update
timeout_policy: "30m"
dependency: price-feed
- job_id: weekly-report
cron: "0 17 * * 5"
lease_owner: report
timeout_policy: "1h"
dependency: report-generator
- job_id: monthly-optimization
cron: "0 2 1 * *"
lease_owner: optimization
timeout_policy: "3h"
dependency: optimizer
audit_fields:
- JobId
- RunId
- State
- StartedAt
- FinishedAt
- ResourceKey
- Reason
idempotency:
required: true
key_pattern: "{job_id}:{resource_key}:{yyyyMMddHHmm}"
notes:
- "상태 전이와 감사 추적은 append-only JSONL로 남긴다."
- "실행 재개는 retrying 이후에만 허용한다."
- "숫자 계산은 여기서 하지 않는다."
+112
View File
@@ -2329,6 +2329,118 @@ dag:
- Temp/wbs_10_dotnet_parity_contract_v1.json
strict: true
timeout_sec: 60
validate_dotnet_provenance_contract:
artifact_policy: keep
cache_key: validate_dotnet_provenance_contract_v1
command:
- python
- tools/validate_dotnet_provenance_contract_v1.py
depends_on: []
id: validate_dotnet_provenance_contract
inputs:
- tools/validate_dotnet_provenance_contract_v1.py
- docs/WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml
note: WBS-10 provenance payload 표준 계약과 필수 payload 표본을 검증한다.
outputs:
- Temp/wbs_10_dotnet_provenance_contract_v1.json
strict: true
timeout_sec: 60
validate_dotnet_scheduler_contract:
artifact_policy: keep
cache_key: validate_dotnet_scheduler_contract_v1
command:
- python
- tools/validate_dotnet_scheduler_contract_v1.py
depends_on: []
id: validate_dotnet_scheduler_contract
inputs:
- tools/validate_dotnet_scheduler_contract_v1.py
- docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml
note: WBS-10 scheduler 상태 전이, 감사, idempotency 계약을 검증한다.
outputs:
- Temp/wbs_10_dotnet_scheduler_contract_v1.json
strict: true
timeout_sec: 60
validate_dotnet_normalization_contract:
artifact_policy: keep
cache_key: validate_dotnet_normalization_contract_v1
command:
- python
- tools/validate_dotnet_normalization_contract_v1.py
depends_on: []
id: validate_dotnet_normalization_contract
inputs:
- tools/validate_dotnet_normalization_contract_v1.py
- docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml
note: WBS-10 정규화 원장과 역정규화 read model 경계를 검증한다.
outputs:
- Temp/wbs_10_dotnet_normalization_contract_v1.json
strict: true
timeout_sec: 60
validate_dotnet_idempotency_contract:
artifact_policy: keep
cache_key: validate_dotnet_idempotency_contract_v1
command:
- python
- tools/validate_dotnet_idempotency_contract_v1.py
depends_on: []
id: validate_dotnet_idempotency_contract
inputs:
- tools/validate_dotnet_idempotency_contract_v1.py
- docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml
note: WBS-10 lock/lease/idempotency 경계를 검증한다.
outputs:
- Temp/wbs_10_dotnet_idempotency_contract_v1.json
strict: true
timeout_sec: 60
validate_dotnet_cicd_chain_contract:
artifact_policy: keep
cache_key: validate_dotnet_cicd_chain_contract_v1
command:
- python
- tools/validate_dotnet_cicd_chain_contract_v1.py
depends_on: []
id: validate_dotnet_cicd_chain_contract
inputs:
- tools/validate_dotnet_cicd_chain_contract_v1.py
- docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml
note: WBS-10 CI → Prepare Release → Deploy to Production 순차 게이트를 검증한다.
outputs:
- Temp/wbs_10_dotnet_cicd_chain_contract_v1.json
strict: true
timeout_sec: 60
validate_dotnet_domain_parity_backlog:
artifact_policy: keep
cache_key: validate_dotnet_domain_parity_backlog_v1
command:
- python
- tools/validate_dotnet_domain_parity_backlog_v1.py
depends_on: []
id: validate_dotnet_domain_parity_backlog
inputs:
- tools/validate_dotnet_domain_parity_backlog_v1.py
- docs/WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml
note: WBS-10 핵심 계산기 parity 대상과 우선순위를 검증한다.
outputs:
- Temp/wbs_10_dotnet_domain_parity_backlog_v1.json
strict: true
timeout_sec: 60
validate_dotnet_read_model_contract:
artifact_policy: keep
cache_key: validate_dotnet_read_model_contract_v1
command:
- python
- tools/validate_dotnet_read_model_contract_v1.py
depends_on: []
id: validate_dotnet_read_model_contract
inputs:
- tools/validate_dotnet_read_model_contract_v1.py
- docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml
note: WBS-10 운영 조회용 read model 경계를 검증한다.
outputs:
- Temp/wbs_10_dotnet_read_model_contract_v1.json
strict: true
timeout_sec: 60
validate_specs:
artifact_policy: keep
cache_key: validate_specs_v1
@@ -0,0 +1,13 @@
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Interfaces;
public interface ICollectionReadModelService
{
Task<CollectionDashboardStateRecord> GetDashboardStateAsync();
Task<List<CollectionRunRecord>> GetRecentRunsAsync(int limit = 20);
Task<List<CollectionSnapshotRecord>> GetRunSnapshotsAsync(string runId);
Task<List<CollectionErrorRecord>> GetRunErrorsAsync(string runId, int limit = 50);
Task<List<CollectionSnapshotRecord>> GetLatestSnapshotsForTickerAsync(string ticker, int limit = 10);
Task<List<PriceHistorySummaryRecord>> GetPriceHistorySummaryAsync();
}
@@ -0,0 +1,10 @@
namespace QuantEngine.Application.Models;
public sealed record CollectionExecutionAudit(
string RunId,
string State,
DateTimeOffset StartedAt,
DateTimeOffset? FinishedAt,
int SuccessCount,
int ErrorCount,
string? Message);
@@ -0,0 +1,21 @@
using QuantEngine.Application.Interfaces;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services;
public sealed class CollectionReadModelService : ICollectionReadModelService
{
private readonly ICollectionRepository _repository;
public CollectionReadModelService(ICollectionRepository repository)
{
_repository = repository;
}
public Task<CollectionDashboardStateRecord> GetDashboardStateAsync() => _repository.GetDashboardStateAsync();
public Task<List<CollectionRunRecord>> GetRecentRunsAsync(int limit = 20) => _repository.GetRecentRunsAsync(limit);
public Task<List<CollectionSnapshotRecord>> GetRunSnapshotsAsync(string runId) => _repository.GetRunSnapshotsAsync(runId);
public Task<List<CollectionErrorRecord>> GetRunErrorsAsync(string runId, int limit = 50) => _repository.GetRunErrorsAsync(runId, limit);
public Task<List<CollectionSnapshotRecord>> GetLatestSnapshotsForTickerAsync(string ticker, int limit = 10) => _repository.GetLatestSnapshotsForTickerAsync(ticker, limit);
public Task<List<PriceHistorySummaryRecord>> GetPriceHistorySummaryAsync() => _repository.GetPriceHistorySummaryAsync();
}
@@ -1,22 +1,13 @@
using System.Text.Json;
using QuantEngine.Core.Interfaces;
using QuantEngine.Application.Interfaces;
namespace QuantEngine.Application.Services;
public class DataCollectionService
{
private readonly IKisApiClient _kisApiClient;
private readonly ICollectionRepository _repository;
private readonly ICollectionOrchestrator _orchestrator;
public DataCollectionService(
IKisApiClient kisApiClient,
ICollectionRepository repository,
ICollectionOrchestrator orchestrator)
public DataCollectionService(ICollectionOrchestrator orchestrator)
{
_kisApiClient = kisApiClient;
_repository = repository;
_orchestrator = orchestrator;
}
@@ -0,0 +1,76 @@
using System.Text.Json;
using QuantEngine.Core.Domain;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services;
public sealed record FactorComputationAudit(
string Ticker,
int StockBars,
int IndexBars,
string State,
DateTimeOffset ComputedAt,
string? SourceVersion);
public sealed class FactorComputationService
{
private readonly HistoryIngestionService _history;
private readonly string _auditRoot;
public FactorComputationService(HistoryIngestionService history)
{
_history = history;
_auditRoot = FindRepoTempRoot();
}
private static string FindRepoTempRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return Path.Combine(current.FullName, "Temp", "factor_audit");
}
current = current.Parent;
}
return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "factor_audit");
}
private void AppendAudit(FactorComputationAudit audit)
{
Directory.CreateDirectory(_auditRoot);
var path = Path.Combine(_auditRoot, $"{audit.Ticker}.jsonl");
File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine);
}
public FactorOutputs Compute(
string ticker,
List<PriceHistoryDailyRecord> stockBars,
List<PriceHistoryDailyRecord> indexBars,
string? sourceVersion = null)
{
var computedAt = DateTimeOffset.UtcNow;
var outputs = FactorCalculator.CalculateFactors(stockBars, indexBars);
AppendAudit(new FactorComputationAudit(ticker, stockBars.Count, indexBars.Count, "SUCCEEDED", computedAt, sourceVersion));
return outputs;
}
public async Task AppendFactorOutputsAsync(
string ticker,
string sourceVersion,
FactorOutputs outputs,
DateTimeOffset? observedAt = null)
{
var when = observedAt ?? DateTimeOffset.UtcNow;
await _history.AppendFactorOutputAsync("momentum_20d", sourceVersion, outputs.Momentum20D, "PASS", sourceVersion, when);
await _history.AppendFactorOutputAsync("momentum_60d", sourceVersion, outputs.Momentum60D, "PASS", sourceVersion, when);
await _history.AppendFactorOutputAsync("momentum_120d", sourceVersion, outputs.Momentum120D, "PASS", sourceVersion, when);
await _history.AppendFactorOutputAsync("atr_20pct", sourceVersion, outputs.Atr20Pct, "PASS", sourceVersion, when);
await _history.AppendFactorOutputAsync("stdev_20d", sourceVersion, outputs.StDev20D, "PASS", sourceVersion, when);
await _history.AppendFactorOutputAsync("beta_60d", sourceVersion, outputs.Beta60D, "PASS", sourceVersion, when);
await _history.AppendFactorOutputAsync("rs_20d", sourceVersion, outputs.Rs20D, "PASS", sourceVersion, when);
AppendAudit(new FactorComputationAudit(ticker, 0, 0, "PERSISTED", when, sourceVersion));
}
}
@@ -1,10 +1,12 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.IO;
using Microsoft.Extensions.Logging;
using QuantEngine.Core.Interfaces;
using QuantEngine.Application.Interfaces;
using QuantEngine.Application.Services;
using QuantEngine.Application.Models;
namespace QuantEngine.Application.Services;
@@ -15,6 +17,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
private readonly PriceDataNormalizer _normalizer;
private readonly SourcePriorityResolver _priorityResolver;
private readonly ILogger<KisDataCollectionOrchestrator> _logger;
private readonly string _auditRoot;
public KisDataCollectionOrchestrator(
IKisApiClient kisApiClient,
@@ -28,6 +31,28 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
_normalizer = normalizer;
_priorityResolver = priorityResolver;
_logger = logger;
_auditRoot = FindRepoTempRoot();
}
private static string FindRepoTempRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return Path.Combine(current.FullName, "Temp", "collection_audit");
}
current = current.Parent;
}
return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "collection_audit");
}
private void AppendAudit(CollectionExecutionAudit audit)
{
Directory.CreateDirectory(_auditRoot);
var path = Path.Combine(_auditRoot, $"{audit.RunId}.jsonl");
File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine);
}
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
@@ -45,6 +70,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
try
{
_logger.LogInformation("Starting collection run {RunId}", runId);
AppendAudit(new CollectionExecutionAudit(runId, "RUNNING", DateTimeOffset.UtcNow, null, 0, 0, "started"));
var kisSource = new KisApiPriceSource(_kisApiClient);
var rows = new List<Dictionary<string, object>>();
@@ -157,6 +183,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
result.SourceCounts = sourceCounts;
result.Rows = rows;
result.Errors = errors;
AppendAudit(new CollectionExecutionAudit(runId, result.Status, DateTimeOffset.Parse(startedAt), DateTimeOffset.Parse(finishedAt), result.SuccessCount, result.ErrorCount, "finished"));
// Save run record
await _repository.SaveRunAsync(new CollectionRunRecord(
@@ -204,6 +231,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
result.Status = "FAILED";
result.FinishedAt = DataNormalizationHelper.KstNowIso();
result.ErrorMessage = ex.Message;
AppendAudit(new CollectionExecutionAudit(runId, result.Status, DateTimeOffset.Parse(startedAt), DateTimeOffset.Parse(result.FinishedAt), result.SuccessCount, result.ErrorCount, ex.Message));
return result;
}
}
@@ -358,4 +386,3 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
}
@@ -109,5 +109,44 @@ namespace QuantEngine.Core.Tests
Assert.Equal(expectedStDev, outputs.StDev20D, 5);
}
[Fact]
public void CalculateFactors_UnsortedInput_ProducesSameOutputs()
{
var stock = CreateMockBars("005930", 100.0, 1.0, 130);
var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130);
stock.Reverse();
index.Reverse();
var outputs = FactorCalculator.CalculateFactors(stock, index);
double expectedMom20 = (20.0 / 209.0) * 100.0;
Assert.Equal(expectedMom20, outputs.Momentum20D, 5);
Assert.Equal(1.0, outputs.Beta60D);
}
[Fact]
public void CalculateFactors_DuplicateDates_UsesLastOccurrencePerDate()
{
var stock = CreateMockBars("005930", 100.0, 1.0, 130);
var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130);
var duplicateDate = stock[129].TradeDate;
stock.Add(new PriceHistoryDailyRecord(
"005930",
duplicateDate,
1000m,
1002m,
998m,
1001m,
100000,
"TEST_SOURCE"));
var outputs = FactorCalculator.CalculateFactors(stock, index);
Assert.True(outputs.Momentum20D > 0);
Assert.True(outputs.Atr20Pct > 0);
}
}
}
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Moq;
using QuantEngine.Application.Services;
using QuantEngine.Core.Domain;
using QuantEngine.Core.Interfaces;
using Xunit;
namespace QuantEngine.Core.Tests;
public class FactorComputationServiceTests
{
[Fact]
public async Task ComputeAndAppendFactorOutputs_WritesAuditAndHistory()
{
var storeMock = new Mock<IPostgresqlHistoryStore>();
storeMock.Setup(s => s.AppendAsync(It.IsAny<string>(), It.IsAny<IDictionary<string, object?>>()))
.ReturnsAsync(1);
var history = new HistoryIngestionService(storeMock.Object);
var service = new FactorComputationService(history);
var root = FindRepoRoot();
var auditPath = Path.Combine(root, "Temp", "factor_audit", "005930.jsonl");
if (File.Exists(auditPath))
{
File.Delete(auditPath);
}
var outputs = new FactorOutputs(1, 2, 3, 4, 5, 6, 7);
await service.AppendFactorOutputsAsync("005930", "v1", outputs);
storeMock.Verify(s => s.AppendAsync("factor_output_history", It.IsAny<IDictionary<string, object?>>()), Times.Exactly(7));
Assert.True(File.Exists(auditPath));
Assert.Contains("PERSISTED", File.ReadAllText(auditPath));
}
private static string FindRepoRoot()
{
var current = new DirectoryInfo(System.AppContext.BaseDirectory);
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Repository root not found.");
}
}
@@ -93,6 +93,11 @@ public class KisDataCollectionOrchestratorTests
var runId = "test-run-002";
var ticker = "005930";
var account = "mock";
var auditPath = Path.Combine(FindRepoRoot(), "Temp", "collection_audit", $"{runId}.jsonl");
if (File.Exists(auditPath))
{
File.Delete(auditPath);
}
_repositoryMock
.Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny<int>()))
@@ -128,6 +133,8 @@ public class KisDataCollectionOrchestratorTests
Assert.NotNull(result);
Assert.Equal("COMPLETED", result.Status);
Assert.Equal(1, result.SuccessCount);
Assert.True(File.Exists(auditPath));
Assert.Contains("COMPLETED", File.ReadAllText(auditPath));
_kisApiClientMock.Verify(
k => k.GetCurrentPriceAsync(ticker, account),
@@ -390,4 +397,19 @@ public class KisDataCollectionOrchestratorTests
Assert.False(result);
Assert.Null(args[2]);
}
private static string FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Repository root not found.");
}
}
@@ -4,6 +4,7 @@ using System.Reflection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Hangfire;
using Hangfire.Common;
using QuantEngine.Web.Services;
@@ -33,7 +34,8 @@ public class SchedulerServiceTests
recurringJobManagerMock.Object,
scopeFactoryMock.Object,
configMock.Object,
parser
parser,
Options.Create(new SchedulerServiceOptions())
);
// Act
@@ -87,6 +89,26 @@ public class SchedulerServiceTests
Assert.Contains(defs, d => d.JobId == "monthly-optimization" && d.IsRecurring);
}
[Fact]
public async Task FetchPriceAsync_WritesAuditTrail()
{
var root = FindRepoRoot();
var auditDir = Path.Combine(root, "Temp", "scheduler_audit");
if (Directory.Exists(auditDir))
{
Directory.Delete(auditDir, true);
}
var service = CreateService();
await service.FetchPriceAsync("005930");
var auditPath = Path.Combine(auditDir, "fetch-price.jsonl");
Assert.True(File.Exists(auditPath));
var lines = File.ReadAllLines(auditPath);
Assert.NotEmpty(lines);
Assert.Contains("\"State\":\"SUCCEEDED\"", lines[0]);
}
[Fact]
public void LoadTickersFromJson_WhenFileMissing_FallsBackToDefaultUniverse()
{
@@ -178,7 +200,8 @@ public class SchedulerServiceTests
recurringJobManagerMock.Object,
scopeFactoryMock.Object,
configMock.Object,
new GatherTradingDataParser()
new GatherTradingDataParser(),
Options.Create(new SchedulerServiceOptions())
);
}
@@ -28,6 +28,9 @@ namespace QuantEngine.Core.Domain
public static class ExitDecisions
{
private static bool IsValidNumber(double? value)
=> value.HasValue && !double.IsNaN(value.Value) && !double.IsInfinity(value.Value);
public static StopPriceResult ComputeStopPriceCore(
double? entryPrice,
double? atr20,
@@ -36,7 +39,7 @@ namespace QuantEngine.Core.Domain
{
var result = new StopPriceResult();
if (!entryPrice.HasValue)
if (!IsValidNumber(entryPrice))
{
result.StopPrice = null;
result.StopPriceStatus = "NO_STOP_PRICE";
@@ -44,38 +47,45 @@ namespace QuantEngine.Core.Domain
return result;
}
if (!atr20.HasValue && !atrMultiplier.HasValue)
if (!IsValidNumber(atr20) && !IsValidNumber(atrMultiplier))
{
result.StopPrice = entryPrice.Value * 0.92;
result.StopPrice = entryPrice.GetValueOrDefault() * 0.92;
result.StopPriceStatus = "DATA_MISSING — 하네스 업데이트 필요";
result.DataMissing.Add("atr20");
return result;
}
if (!atrMultiplier.HasValue && (!currentPrice.HasValue || currentPrice.Value == 0))
var hasCurrentPrice = IsValidNumber(currentPrice) && currentPrice!.Value != 0;
if (!IsValidNumber(atrMultiplier) && !hasCurrentPrice)
{
result.StopPrice = entryPrice.Value * 0.92;
result.StopPrice = entryPrice.GetValueOrDefault() * 0.92;
result.StopPriceStatus = "DATA_MISSING — 하네스 업데이트 필요";
if (!atr20.HasValue) result.DataMissing.Add("atr20");
if (!currentPrice.HasValue || currentPrice.Value == 0) result.DataMissing.Add("current_price");
if (!IsValidNumber(atr20)) result.DataMissing.Add("atr20");
if (!hasCurrentPrice) result.DataMissing.Add("current_price");
return result;
}
if (!atrMultiplier.HasValue)
if (!IsValidNumber(atrMultiplier))
{
double atr20Pct = (atr20!.Value / currentPrice!.Value) * 100;
var atr20Value = atr20.GetValueOrDefault();
var currentPriceValue = currentPrice.GetValueOrDefault();
double atr20Pct = (atr20Value / currentPriceValue) * 100;
atrMultiplier = atr20Pct >= 8 ? 2.0 : 1.5;
result.Atr20Pct = atr20Pct;
}
else
{
result.Atr20Pct = (currentPrice.HasValue && currentPrice.Value != 0)
? (atr20!.Value / currentPrice.Value) * 100
result.Atr20Pct = hasCurrentPrice
? (atr20.GetValueOrDefault() / currentPrice.GetValueOrDefault()) * 100
: (double?)null;
}
var entryPriceValue = entryPrice.GetValueOrDefault();
var atr20FinalValue = atr20.GetValueOrDefault();
var atrMultiplierValue = atrMultiplier.GetValueOrDefault();
result.AtrMultiplier = atrMultiplier;
result.StopPrice = Math.Max(entryPrice.Value * 0.92, entryPrice.Value - atr20!.Value * atrMultiplier.Value);
result.StopPrice = Math.Max(entryPriceValue * 0.92, entryPriceValue - atr20FinalValue * atrMultiplierValue);
result.StopPriceStatus = "PASS";
return result;
@@ -26,31 +26,30 @@ namespace QuantEngine.Core.Domain
return new FactorOutputs(0, 0, 0, 0, 0, 1.0, 0);
}
// Ensure sorted chronologically (oldest to newest)
var sortedStock = stockBars.OrderBy(b => b.TradeDate).ToList();
var sortedIndex = indexBars?.OrderBy(b => b.TradeDate).ToList() ?? new List<PriceHistoryDailyRecord>();
var sortedStock = NormalizeBars(stockBars);
var sortedIndex = NormalizeBars(indexBars);
return new FactorOutputs(
CalculateMomentum(sortedStock, 20),
CalculateMomentum(sortedStock, 60),
CalculateMomentum(sortedStock, 120),
CalculateAtr20Pct(sortedStock),
CalculatePriceStDev20D(sortedStock),
CalculateBeta60D(sortedStock, sortedIndex),
CalculateRs20D(sortedStock, sortedIndex));
}
int count = sortedStock.Count;
double closeToday = (double)sortedStock[^1].Close;
private static List<PriceHistoryDailyRecord> NormalizeBars(List<PriceHistoryDailyRecord>? bars)
{
if (bars == null || bars.Count == 0)
{
return new List<PriceHistoryDailyRecord>();
}
// 1. Momentum
double mom20 = CalculateMomentum(sortedStock, 20);
double mom60 = CalculateMomentum(sortedStock, 60);
double mom120 = CalculateMomentum(sortedStock, 120);
// 2. ATR 20D Percentage
double atrPct = CalculateAtr20Pct(sortedStock);
// 3. Price Standard Deviation 20D
double stdev = CalculatePriceStDev20D(sortedStock);
// 4. Beta 60D
double beta = CalculateBeta60D(sortedStock, sortedIndex);
// 5. Relative Strength (RS) 20D (vs Index)
double rs = CalculateRs20D(sortedStock, sortedIndex);
return new FactorOutputs(mom20, mom60, mom120, atrPct, stdev, beta, rs);
return bars
.OrderBy(b => b.TradeDate)
.GroupBy(b => b.TradeDate)
.Select(g => g.Last())
.ToList();
}
private static double CalculateMomentum(List<PriceHistoryDailyRecord> bars, int period)
@@ -58,7 +57,7 @@ namespace QuantEngine.Core.Domain
if (bars.Count <= period) return 0.0;
double current = (double)bars[^1].Close;
double prev = (double)bars[^(period + 1)].Close;
if (prev <= 0.0) return 0.0;
if (prev <= 0.0 || double.IsNaN(prev) || double.IsInfinity(prev)) return 0.0;
return ((current - prev) / prev) * 100.0;
}
@@ -79,7 +78,7 @@ namespace QuantEngine.Core.Domain
double atr = trList.Average();
double closeToday = (double)bars[^1].Close;
if (closeToday <= 0.0) return 0.0;
if (closeToday <= 0.0 || double.IsNaN(closeToday) || double.IsInfinity(closeToday)) return 0.0;
return (atr / closeToday) * 100.0;
}
@@ -90,8 +89,6 @@ namespace QuantEngine.Core.Domain
var subset = bars.Skip(bars.Count - 20).Select(b => (double)b.Close).ToList();
double avg = subset.Average();
double sumOfSquares = subset.Sum(val => Math.Pow(val - avg, 2));
// Sample standard deviation (N-1)
return Math.Sqrt(sumOfSquares / (subset.Count - 1));
}
@@ -99,11 +96,9 @@ namespace QuantEngine.Core.Domain
{
if (stock.Count < 61 || index.Count < 61) return 1.0;
// Align daily returns
var stockMap = stock.ToDictionary(b => b.TradeDate);
var indexMap = index.ToDictionary(b => b.TradeDate);
// Compute returns for overlapping dates
var overlappingDates = stockMap.Keys.Intersect(indexMap.Keys).OrderBy(d => d).ToList();
if (overlappingDates.Count < 61) return 1.0;
@@ -113,7 +108,6 @@ namespace QuantEngine.Core.Domain
var stockReturns = new List<double>();
var indexReturns = new List<double>();
// Calculate returns starting from last 60 days
int startIdx = Math.Max(1, alignedStock.Count - 60);
for (int i = startIdx; i < alignedStock.Count; i++)
{
@@ -154,7 +148,6 @@ namespace QuantEngine.Core.Domain
{
if (stock.Count < 21 || index.Count < 21) return 0.0;
// Align dates
var stockMap = stock.ToDictionary(b => b.TradeDate);
var indexMap = index.ToDictionary(b => b.TradeDate);
@@ -65,6 +65,9 @@ namespace QuantEngine.Core.Domain
public static class FormulaEngine
{
private static bool IsValidNumber(double? value)
=> value.HasValue && !double.IsNaN(value.Value) && !double.IsInfinity(value.Value);
public static TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
{
var reasons = new List<string>();
@@ -98,9 +101,9 @@ namespace QuantEngine.Core.Domain
reasons.Add("entry_block");
}
if (leaderTotal.HasValue && !double.IsNaN(leaderTotal.Value) && !double.IsInfinity(leaderTotal.Value))
if (IsValidNumber(leaderTotal))
{
if (leaderTotal.Value >= 4)
if (leaderTotal!.Value >= 4)
{
entryScore += 20;
reasons.Add("leader_scan>=4");
@@ -116,9 +119,9 @@ namespace QuantEngine.Core.Domain
entryScore += 10;
}
if (flowCredit.HasValue && !double.IsNaN(flowCredit.Value) && !double.IsInfinity(flowCredit.Value))
if (IsValidNumber(flowCredit))
{
if (flowCredit.Value >= 0.7)
if (flowCredit!.Value >= 0.7)
{
entryScore += 20;
reasons.Add("flow_strong");
@@ -147,9 +150,9 @@ namespace QuantEngine.Core.Domain
reasons.Add("anti_climax_block");
}
if (ma20Slope.HasValue && !double.IsNaN(ma20Slope.Value) && !double.IsInfinity(ma20Slope.Value))
if (IsValidNumber(ma20Slope))
{
if (ma20Slope.Value > 0)
if (ma20Slope!.Value > 0)
{
entryScore += 8;
}
@@ -161,9 +164,9 @@ namespace QuantEngine.Core.Domain
}
}
if (disparity.HasValue && !double.IsNaN(disparity.Value) && !double.IsInfinity(disparity.Value))
if (IsValidNumber(disparity))
{
if (disparity.Value >= -5 && disparity.Value <= 4)
if (disparity!.Value >= -5 && disparity.Value <= 4)
{
entryScore += 10;
}
@@ -185,9 +188,9 @@ namespace QuantEngine.Core.Domain
}
}
if (rsi14.HasValue && !double.IsNaN(rsi14.Value) && !double.IsInfinity(rsi14.Value))
if (IsValidNumber(rsi14))
{
if (rsi14.Value >= 40 && rsi14.Value <= 65)
if (rsi14!.Value >= 40 && rsi14.Value <= 65)
{
entryScore += 10;
}
@@ -209,7 +212,7 @@ namespace QuantEngine.Core.Domain
}
}
if (avgTradeValue5D.HasValue && !double.IsNaN(avgTradeValue5D.Value) && !double.IsInfinity(avgTradeValue5D.Value) && avgTradeValue5D.Value >= 50 && (!spreadPct.HasValue || double.IsNaN(spreadPct.Value) || spreadPct.Value <= 0.8))
if (IsValidNumber(avgTradeValue5D) && avgTradeValue5D!.Value >= 50 && (!IsValidNumber(spreadPct) || spreadPct!.Value <= 0.8))
{
entryScore += 10;
}
@@ -219,9 +222,9 @@ namespace QuantEngine.Core.Domain
reasons.Add("liquidity_or_spread_fail");
}
if (rwPartial.HasValue && !double.IsNaN(rwPartial.Value) && !double.IsInfinity(rwPartial.Value))
if (IsValidNumber(rwPartial))
{
exitScore += Math.Min(100.0, Math.Max(0.0, (int)rwPartial.Value * 25.0));
exitScore += Math.Min(100.0, Math.Max(0.0, (int)rwPartial!.Value * 25.0));
}
if (!string.IsNullOrEmpty(exitSignal))
@@ -230,13 +233,13 @@ namespace QuantEngine.Core.Domain
exitScore += parts.Length * 10;
}
if (daysToTimeStop.HasValue && !double.IsNaN(daysToTimeStop.Value) && daysToTimeStop.Value >= 0 && daysToTimeStop.Value <= 7)
if (IsValidNumber(daysToTimeStop) && daysToTimeStop!.Value >= 0 && daysToTimeStop.Value <= 7)
{
exitScore += 20;
reasons.Add("time_stop_near");
}
if (profitPct.HasValue && !double.IsNaN(profitPct.Value) && profitPct.Value >= 10)
if (IsValidNumber(profitPct) && profitPct!.Value >= 10)
{
exitScore += 15;
reasons.Add("profit_protect_zone");
@@ -249,15 +252,15 @@ namespace QuantEngine.Core.Domain
double? atr20 = GetNullableDouble(ctx, "atr20");
string priceStatus = GetString(ctx, "priceStatus");
if (priceStatus != "PRICE_OK" || !atr20.HasValue || double.IsNaN(atr20.Value) || double.IsInfinity(atr20.Value))
if (priceStatus != "PRICE_OK" || !IsValidNumber(atr20))
{
action = "OBSERVE_DATA_MISSING";
}
else if (exitScore >= 75 || (rwPartial.HasValue && rwPartial.Value >= 4))
else if (exitScore >= 75 || (IsValidNumber(rwPartial) && rwPartial!.Value >= 4))
{
action = "STOP_OR_TIME_EXIT_READY";
}
else if (exitScore >= 50 || (rwPartial.HasValue && rwPartial.Value >= 3))
else if (exitScore >= 50 || (IsValidNumber(rwPartial) && rwPartial!.Value >= 3))
{
action = "EXIT_REVIEW";
}
@@ -7,11 +7,11 @@ namespace QuantEngine.Web.Endpoints;
public class GetCollectionStateEndpoint : EndpointWithoutRequest<CollectionDashboardStateRecord>
{
private readonly ICollectionRepository _repo;
private readonly ICollectionReadModelService _readModelService;
public GetCollectionStateEndpoint(ICollectionRepository repo)
public GetCollectionStateEndpoint(ICollectionReadModelService readModelService)
{
_repo = repo;
_readModelService = readModelService;
}
public override void Configure()
@@ -27,7 +27,7 @@ public class GetCollectionStateEndpoint : EndpointWithoutRequest<CollectionDashb
{
try
{
var state = await _repo.GetDashboardStateAsync();
var state = await _readModelService.GetDashboardStateAsync();
await SendOkAsync(state, ct);
}
catch
@@ -50,11 +50,11 @@ public class GetRecentRunsResponse
public class GetRecentRunsEndpoint : Endpoint<GetRecentRunsRequest, GetRecentRunsResponse>
{
private readonly ICollectionRepository _repo;
private readonly ICollectionReadModelService _readModelService;
public GetRecentRunsEndpoint(ICollectionRepository repo)
public GetRecentRunsEndpoint(ICollectionReadModelService readModelService)
{
_repo = repo;
_readModelService = readModelService;
}
public override void Configure()
@@ -70,7 +70,7 @@ public class GetRecentRunsEndpoint : Endpoint<GetRecentRunsRequest, GetRecentRun
{
try
{
var runs = await _repo.GetRecentRunsAsync(req.Limit);
var runs = await _readModelService.GetRecentRunsAsync(req.Limit);
await SendOkAsync(new GetRecentRunsResponse { Runs = runs, Count = runs.Count }, ct);
}
catch
@@ -94,11 +94,11 @@ public class GetRunSnapshotsResponse
public class GetRunSnapshotsEndpoint : Endpoint<GetRunSnapshotsRequest, GetRunSnapshotsResponse>
{
private readonly ICollectionRepository _repo;
private readonly ICollectionReadModelService _readModelService;
public GetRunSnapshotsEndpoint(ICollectionRepository repo)
public GetRunSnapshotsEndpoint(ICollectionReadModelService readModelService)
{
_repo = repo;
_readModelService = readModelService;
}
public override void Configure()
@@ -115,7 +115,7 @@ public class GetRunSnapshotsEndpoint : Endpoint<GetRunSnapshotsRequest, GetRunSn
{
try
{
var snapshots = await _repo.GetRunSnapshotsAsync(req.RunId);
var snapshots = await _readModelService.GetRunSnapshotsAsync(req.RunId);
await SendOkAsync(new GetRunSnapshotsResponse { RunId = req.RunId, Snapshots = snapshots, Count = snapshots.Count }, ct);
}
catch
@@ -140,11 +140,11 @@ public class GetRunErrorsResponse
public class GetRunErrorsEndpoint : Endpoint<GetRunErrorsRequest, GetRunErrorsResponse>
{
private readonly ICollectionRepository _repo;
private readonly ICollectionReadModelService _readModelService;
public GetRunErrorsEndpoint(ICollectionRepository repo)
public GetRunErrorsEndpoint(ICollectionReadModelService readModelService)
{
_repo = repo;
_readModelService = readModelService;
}
public override void Configure()
@@ -161,7 +161,7 @@ public class GetRunErrorsEndpoint : Endpoint<GetRunErrorsRequest, GetRunErrorsRe
{
try
{
var errors = await _repo.GetRunErrorsAsync(req.RunId, req.Limit);
var errors = await _readModelService.GetRunErrorsAsync(req.RunId, req.Limit);
await SendOkAsync(new GetRunErrorsResponse { RunId = req.RunId, Errors = errors, Count = errors.Count }, ct);
}
catch
@@ -186,11 +186,11 @@ public class GetLatestSnapshotsResponse
public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, GetLatestSnapshotsResponse>
{
private readonly ICollectionRepository _repo;
private readonly ICollectionReadModelService _readModelService;
public GetLatestSnapshotsEndpoint(ICollectionRepository repo)
public GetLatestSnapshotsEndpoint(ICollectionReadModelService readModelService)
{
_repo = repo;
_readModelService = readModelService;
}
public override void Configure()
@@ -206,7 +206,7 @@ public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, Ge
{
try
{
var snapshots = await _repo.GetLatestSnapshotsForTickerAsync(req.Ticker, req.Limit);
var snapshots = await _readModelService.GetLatestSnapshotsForTickerAsync(req.Ticker, req.Limit);
await SendOkAsync(new GetLatestSnapshotsResponse { Ticker = req.Ticker, Snapshots = snapshots, Count = snapshots.Count }, ct);
}
catch
@@ -223,12 +223,12 @@ public class GetPriceHistorySummaryResponse
public class GetPriceHistorySummaryEndpoint : EndpointWithoutRequest<GetPriceHistorySummaryResponse>
{
private readonly ICollectionRepository _repo;
private readonly ICollectionReadModelService _readModelService;
private readonly ILogger<GetPriceHistorySummaryEndpoint> _logger;
public GetPriceHistorySummaryEndpoint(ICollectionRepository repo, ILogger<GetPriceHistorySummaryEndpoint> logger)
public GetPriceHistorySummaryEndpoint(ICollectionReadModelService readModelService, ILogger<GetPriceHistorySummaryEndpoint> logger)
{
_repo = repo;
_readModelService = readModelService;
_logger = logger;
}
@@ -245,7 +245,7 @@ public class GetPriceHistorySummaryEndpoint : EndpointWithoutRequest<GetPriceHis
{
try
{
var summary = await _repo.GetPriceHistorySummaryAsync();
var summary = await _readModelService.GetPriceHistorySummaryAsync();
await SendOkAsync(new GetPriceHistorySummaryResponse { Tickers = summary }, ct);
}
catch (Exception ex)
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Application.Interfaces;
using QuantEngine.Core.Interfaces;
using QuantEngine.Web.Services;
@@ -8,16 +9,16 @@ namespace QuantEngine.Web.Pages.Admin.Collection;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
private readonly ICollectionRepository _collectionRepository;
private readonly ICollectionReadModelService _collectionReadModelService;
private readonly ILogger<IndexModel> _logger;
public List<CollectionRunRecord>? Runs { get; set; }
public List<PriceHistorySummaryRecord>? HistorySummary { get; set; }
public string? Message { get; set; }
public IndexModel(ICollectionRepository collectionRepository, ILogger<IndexModel> logger)
public IndexModel(ICollectionReadModelService collectionReadModelService, ILogger<IndexModel> logger)
{
_collectionRepository = collectionRepository;
_collectionReadModelService = collectionReadModelService;
_logger = logger;
}
@@ -25,8 +26,8 @@ public class IndexModel : PageModel
{
try
{
Runs = await _collectionRepository.GetRecentRunsAsync(limit: 20);
HistorySummary = await _collectionRepository.GetPriceHistorySummaryAsync();
Runs = await _collectionReadModelService.GetRecentRunsAsync(limit: 20);
HistorySummary = await _collectionReadModelService.GetPriceHistorySummaryAsync();
}
catch (Exception ex)
{
@@ -2,6 +2,7 @@ using System.IO;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Application.Interfaces;
using QuantEngine.Core.Interfaces;
using QuantEngine.Web.Services;
@@ -11,7 +12,7 @@ namespace QuantEngine.Web.Pages.Admin.Dashboard;
public class IndexModel : PageModel
{
private readonly IWorkspaceRepository _workspaceRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly ICollectionReadModelService _collectionReadModelService;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<IndexModel> _logger;
@@ -95,12 +96,12 @@ public class IndexModel : PageModel
public IndexModel(
IWorkspaceRepository workspaceRepository,
ICollectionRepository collectionRepository,
ICollectionReadModelService collectionReadModelService,
IWebHostEnvironment environment,
ILogger<IndexModel> logger)
{
_workspaceRepository = workspaceRepository;
_collectionRepository = collectionRepository;
_collectionReadModelService = collectionReadModelService;
_environment = environment;
_logger = logger;
}
@@ -112,7 +113,7 @@ public class IndexModel : PageModel
var accounts = await _workspaceRepository.GetAccountsAsync();
ActiveUsersCount = accounts.Count(a => string.Equals(a.IsActive, "true", StringComparison.OrdinalIgnoreCase));
var dashboard = await _collectionRepository.GetDashboardStateAsync();
var dashboard = await _collectionReadModelService.GetDashboardStateAsync();
RecentRunsCount = string.IsNullOrEmpty(dashboard?.LastRunId) ? 0 : 1;
// These two queries only complete if the DB round-trip actually
+2
View File
@@ -108,6 +108,7 @@ try
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
builder.Services.AddScoped<HistoryIngestionService>();
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
builder.Services.AddScoped<ICollectionReadModelService, CollectionReadModelService>();
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
@@ -116,6 +117,7 @@ try
builder.Services.AddScoped<PriceDataNormalizer>();
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
builder.Services.AddScoped<IPriceHistoryReader, PriceHistoryReader>();
builder.Services.AddOptions<SchedulerServiceOptions>();
// Hangfire Background Jobs
try
@@ -1,11 +1,24 @@
namespace QuantEngine.Web.Services;
using System;
public sealed record SchedulerJobDefinition(
string JobId,
string Cron,
string Description,
bool IsRecurring);
public sealed record SchedulerServiceOptions
{
public List<SchedulerJobDefinition> JobDefinitions { get; init; } = new()
{
new SchedulerJobDefinition("daily-collection", "0 9 * * *", "Daily data collection", true),
new SchedulerJobDefinition("hourly-price-update", "0 9,11,13,15 * * 1-5", "Hourly price update", true),
new SchedulerJobDefinition("weekly-report", "0 17 * * 5", "Weekly report generation", true),
new SchedulerJobDefinition("monthly-optimization", "0 2 1 * *", "Monthly optimization", true),
};
}
public sealed record SchedulerJobExecutionAudit(
string JobId,
string RunId,
@@ -3,11 +3,13 @@ using Hangfire.States;
using Hangfire.Dashboard;
using Hangfire.PostgreSql;
using Hangfire.MemoryStorage;
using System.Text.Json;
using System.Linq.Expressions;
using QuantEngine.Application.Services;
using QuantEngine.Application.Interfaces;
using QuantEngine.Infrastructure.Data;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace QuantEngine.Web.Services;
@@ -22,6 +24,8 @@ public class SchedulerService
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
private readonly GatherTradingDataParser _parser;
private readonly SchedulerServiceOptions _options;
private readonly string _auditRoot;
public SchedulerService(
ILogger<SchedulerService> logger,
@@ -29,7 +33,8 @@ public class SchedulerService
IRecurringJobManager recurringJobManager,
IServiceScopeFactory scopeFactory,
IConfiguration configuration,
GatherTradingDataParser parser)
GatherTradingDataParser parser,
IOptions<SchedulerServiceOptions> options)
{
_logger = logger;
_jobClient = jobClient;
@@ -37,6 +42,56 @@ public class SchedulerService
_scopeFactory = scopeFactory;
_configuration = configuration;
_parser = parser;
_options = options.Value ?? new SchedulerServiceOptions();
_auditRoot = FindRepoTempRoot();
}
private static string FindRepoTempRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return Path.Combine(current.FullName, "Temp", "scheduler_audit");
}
current = current.Parent;
}
return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "scheduler_audit");
}
private void AppendAudit(SchedulerJobExecutionAudit audit)
{
Directory.CreateDirectory(_auditRoot);
var path = Path.Combine(_auditRoot, $"{audit.JobId}.jsonl");
File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine);
}
private async Task<T> ExecuteWithAuditAsync<T>(string jobId, Func<Task<T>> action, Func<T>? fallback = null, string? resourceKey = null)
{
var startedAt = DateTimeOffset.UtcNow;
AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Running, null, startedAt, null, resourceKey));
try
{
var result = await action();
AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, startedAt, DateTimeOffset.UtcNow, resourceKey));
return result;
}
catch (Exception ex)
{
AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, startedAt, DateTimeOffset.UtcNow, resourceKey));
if (fallback is not null)
{
return fallback();
}
throw;
}
}
private async Task ExecuteWithAuditAsync(string jobId, Func<Task> action, string? resourceKey = null)
{
await ExecuteWithAuditAsync(jobId, async () => { await action(); return true; }, resourceKey: resourceKey);
}
private List<string> LoadTickersFromJson()
@@ -76,13 +131,8 @@ public class SchedulerService
}
}
public IReadOnlyList<SchedulerJobDefinition> GetRecurringJobDefinitions() => new[]
{
new SchedulerJobDefinition("daily-collection", "0 9 * * *", "Daily data collection", true),
new SchedulerJobDefinition("hourly-price-update", "0 9,11,13,15 * * 1-5", "Hourly price update", true),
new SchedulerJobDefinition("weekly-report", "0 17 * * 5", "Weekly report generation", true),
new SchedulerJobDefinition("monthly-optimization", "0 2 1 * *", "Monthly optimization", true),
};
public IReadOnlyList<SchedulerJobDefinition> GetRecurringJobDefinitions()
=> _options.JobDefinitions.Count > 0 ? _options.JobDefinitions : new SchedulerServiceOptions().JobDefinitions;
private static string? FindGatherTradingDataJson()
{
@@ -139,6 +189,7 @@ public class SchedulerService
try
{
_logger.LogInformation("Starting daily data collection job at {Time}", DateTime.Now);
AppendAudit(new SchedulerJobExecutionAudit("daily-collection", $"daily-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "collection"));
var tickers = LoadTickersFromJson();
@@ -153,7 +204,10 @@ public class SchedulerService
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
// Execute collection
var result = await orchestrator.RunCollectionAsync(runId, accountMode, tickers);
var result = await ExecuteWithAuditAsync(
"daily-collection",
() => orchestrator.RunCollectionAsync(runId, accountMode, tickers),
resourceKey: "collection");
// Log completion
_logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors",
@@ -173,6 +227,7 @@ public class SchedulerService
try
{
_logger.LogInformation("Starting hourly price update at {Time}", DateTime.Now);
AppendAudit(new SchedulerJobExecutionAudit("hourly-price-update", $"hourly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "price-update"));
var tickers = LoadTickersFromJson();
@@ -186,6 +241,7 @@ public class SchedulerService
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enqueue price update for {Ticker}", ticker);
AppendAudit(new SchedulerJobExecutionAudit("hourly-price-update", $"hourly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Retrying, ex.Message, DateTimeOffset.UtcNow, null, ticker));
}
}
@@ -208,10 +264,12 @@ public class SchedulerService
// TODO: Implement actual price fetching
await Task.Delay(50);
_logger.LogInformation("Price fetched successfully for {Ticker}", ticker);
AppendAudit(new SchedulerJobExecutionAudit("fetch-price", $"fetch-{ticker}-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ticker));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching price for {Ticker}", ticker);
AppendAudit(new SchedulerJobExecutionAudit("fetch-price", $"fetch-{ticker}-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ticker));
}
}
@@ -223,15 +281,18 @@ public class SchedulerService
try
{
_logger.LogInformation("Starting weekly report generation at {Time}", DateTime.Now);
AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "report"));
// TODO: Implement report generation logic
await Task.Delay(500);
_logger.LogInformation("Weekly report generated successfully");
AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "report"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating weekly report");
AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "report"));
}
}
@@ -243,15 +304,18 @@ public class SchedulerService
try
{
_logger.LogInformation("Starting monthly optimization at {Time}", DateTime.Now);
AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "optimization"));
// TODO: Implement optimization logic
await Task.Delay(1000);
_logger.LogInformation("Monthly optimization completed");
AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "optimization"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during monthly optimization");
AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "optimization"));
}
}
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def test_validate_dotnet_cicd_chain_contract_passes() -> None:
root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_cicd_chain_contract_v1.py")],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json.loads(proc.stdout)
assert payload["gate"] == "PASS"
def test_validate_dotnet_cicd_chain_contract_reports_missing_chain() -> None:
root = Path(__file__).resolve().parents[2]
temp = root / "Temp" / "test_wbs_10_cicd_chain_contract_bad.yaml"
temp.write_text(
"formula_id: WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1\ngoal: CI, prepare-release, deploy-prod 순차 게이트를 고정한다.\nworkflows:\n ci:\n name: Validators (Pushes and Pull Requests)\n prepare_release:\n name: Prepare Release\n deploy_prod:\n name: Deploy to Production\ndependency_chain: []\nrequired_guards: [a,b,c,d]\nhealth_checks: [a,b,c,d]\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_cicd_chain_contract_v1.py"), "--contract", str(temp)],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode != 0
payload = json.loads(proc.stdout)
assert payload["gate"] == "FAIL"
assert "dependency_chain" in payload["missing"]
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def test_validate_dotnet_domain_parity_backlog_passes() -> None:
root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_domain_parity_backlog_v1.py")],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json.loads(proc.stdout)
assert payload["gate"] == "PASS"
def test_validate_dotnet_domain_parity_backlog_reports_missing_target() -> None:
root = Path(__file__).resolve().parents[2]
temp = root / "Temp" / "test_wbs_10_domain_parity_backlog_bad.yaml"
temp.write_text(
"formula_id: WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG_V1\ncoverage_min: 1.0\ntolerance_policy:\n numeric_default: 0\n text_default: exact\nparity_targets:\n - target_id: formula_engine_timing\n priority: 1\n - target_id: formula_engine_sell\n priority: 2\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_domain_parity_backlog_v1.py"), "--backlog", str(temp)],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode != 0
payload = json.loads(proc.stdout)
assert payload["gate"] == "FAIL"
assert "exit_stop_price missing" in payload["missing"]
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def test_validate_dotnet_idempotency_contract_passes() -> None:
root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_idempotency_contract_v1.py")],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json.loads(proc.stdout)
assert payload["gate"] == "PASS"
def test_validate_dotnet_idempotency_contract_reports_missing_table() -> None:
root = Path(__file__).resolve().parents[2]
temp = root / "Temp" / "test_wbs_10_idempotency_contract_bad.yaml"
temp.write_text(
"formula_id: WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1\ngoal: 중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다.\nlock_domain:\n canonical_table: wrong\nidempotency_key:\n required: true\nlease_policy:\n required: true\n retry_policy:\n max_attempts: 3\n backoff: exponential\nduplicate_execution_guards: [x]\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_idempotency_contract_v1.py"), "--contract", str(temp)],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode != 0
payload = json.loads(proc.stdout)
assert payload["gate"] == "FAIL"
assert "lock_domain.canonical_table" in payload["missing"]
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def test_validate_dotnet_normalization_contract_passes() -> None:
root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_normalization_contract_v1.py")],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json.loads(proc.stdout)
assert payload["gate"] == "PASS"
def test_validate_dotnet_normalization_contract_reports_missing_view() -> None:
root = Path(__file__).resolve().parents[2]
temp = root / "Temp" / "test_wbs_10_normalization_contract_bad.yaml"
temp.write_text(
"formula_id: WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1\ngoal: 쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다.\ncanonical_write_path:\n schema: engine_history\n tables: [source_observation]\ncanonical_read_path:\n view: wrong_view\nforbidden_patterns: [x]\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_normalization_contract_v1.py"), "--contract", str(temp)],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode != 0
payload = json.loads(proc.stdout)
assert payload["gate"] == "FAIL"
assert "canonical_read_path.view" in payload["missing"]
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def test_validate_dotnet_provenance_contract_passes() -> None:
root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_provenance_contract_v1.py")],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json.loads(proc.stdout)
assert payload["gate"] == "PASS"
def test_validate_dotnet_provenance_contract_reports_missing_payload() -> None:
root = Path(__file__).resolve().parents[2]
temp = root / "Temp" / "test_wbs_10_provenance_contract_bad.yaml"
temp.write_text(
"formula_id: WBS_10_DOTNET_PROVENANCE_CONTRACT_V1\ngoal: 결정/팩터/수집 provenance payload를 표준화한다.\npayloads: []\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_provenance_contract_v1.py"), "--contract", str(temp)],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode != 0
payload = json.loads(proc.stdout)
assert payload["gate"] == "FAIL"
assert "factor_evidence missing" in payload["missing"]
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def test_validate_dotnet_read_model_contract_passes() -> None:
root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_read_model_contract_v1.py")],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json.loads(proc.stdout)
assert payload["gate"] == "PASS"
def test_validate_dotnet_read_model_contract_reports_missing_budget() -> None:
root = Path(__file__).resolve().parents[2]
temp = root / "Temp" / "test_wbs_10_read_model_contract_bad.yaml"
temp.write_text(
"formula_id: WBS_10_DOTNET_READ_MODEL_CONTRACT_V1\ngoal: 운영 화면과 조회 API의 read model 경계를 분리한다.\nread_models:\n - model_id: dashboard_summary\n purpose: x\n source: y\n consumers: [a]\n fields: [b]\nrules: [x,y,z,w]\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_read_model_contract_v1.py"), "--contract", str(temp)],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode != 0
payload = json.loads(proc.stdout)
assert payload["gate"] == "FAIL"
assert "dashboard_summary.staleness_budget" in payload["missing"]
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def test_validate_dotnet_scheduler_contract_passes() -> None:
root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_scheduler_contract_v1.py")],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json.loads(proc.stdout)
assert payload["gate"] == "PASS"
def test_validate_dotnet_scheduler_contract_reports_missing_states() -> None:
root = Path(__file__).resolve().parents[2]
temp = root / "Temp" / "test_wbs_10_scheduler_contract_bad.yaml"
temp.write_text(
"formula_id: WBS_10_DOTNET_SCHEDULER_CONTRACT_V1\ngoal: 스케줄러 상태 전이, 의존성, 재시도, 감사 추적을 표준화한다.\nstate_machine:\n states: [pending, running]\n allowed_transitions: {}\njob_definitions: []\naudit_fields: []\nidempotency:\n required: false\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(root / "tools" / "validate_dotnet_scheduler_contract_v1.py"), "--contract", str(temp)],
cwd=root,
capture_output=True,
text=True,
)
assert proc.returncode != 0
payload = json.loads(proc.stdout)
assert payload["gate"] == "FAIL"
assert "state_machine.states" in payload["missing"]
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
EXPECTED = {
"ci": "Validators (Pushes and Pull Requests)",
"prepare_release": "Prepare Release",
"deploy_prod": "Deploy to Production",
}
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(path)
return yaml.safe_load(path.read_text(encoding="utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet CICD chain contract")
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml")
args = parser.parse_args(argv)
contract_path = Path(args.contract).resolve()
payload: dict[str, Any] = {
"formula_id": "WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1",
"gate": "FAIL",
"missing": [],
"evidence": {"contract": str(contract_path)},
}
try:
data = load_yaml(contract_path)
except FileNotFoundError:
payload["missing"].append("contract missing")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
if data.get("formula_id") != "WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1":
payload["missing"].append("formula_id")
if data.get("goal") != "CI, prepare-release, deploy-prod 순차 게이트를 고정한다.":
payload["missing"].append("goal")
workflows = data.get("workflows") or {}
for key, expected_name in EXPECTED.items():
node = workflows.get(key) or {}
if node.get("name") != expected_name:
payload["missing"].append(f"workflows.{key}.name")
chain = data.get("dependency_chain") or []
if "Validators (Pushes and Pull Requests) -> Prepare Release -> Deploy to Production" not in chain:
payload["missing"].append("dependency_chain")
guards = data.get("required_guards") or []
if len(guards) < 4:
payload["missing"].append("required_guards")
checks = data.get("health_checks") or []
if len(checks) < 4:
payload["missing"].append("health_checks")
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
payload["message"] = (
"WBS-10 dotnet CICD chain contract validation passed."
if payload["gate"] == "PASS"
else "WBS-10 dotnet CICD chain contract validation failed."
)
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_cicd_chain_contract_v1.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
REQUIRED_TARGETS = {
"formula_engine_timing",
"formula_engine_sell",
"formula_engine_final",
"exit_stop_price",
"exit_stop_ladder",
"exit_heat_thresholds",
"factor_calculator",
}
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(path)
return yaml.safe_load(path.read_text(encoding="utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet domain parity backlog")
parser.add_argument("--backlog", default="docs/WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml")
args = parser.parse_args(argv)
backlog_path = Path(args.backlog).resolve()
payload: dict[str, Any] = {
"formula_id": "WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG_V1",
"gate": "FAIL",
"missing": [],
"evidence": {"backlog": str(backlog_path)},
}
try:
data = load_yaml(backlog_path)
except FileNotFoundError:
payload["missing"].append("backlog missing")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
if data.get("formula_id") != "WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG_V1":
payload["missing"].append("formula_id")
if data.get("coverage_min") != 1.0:
payload["missing"].append("coverage_min")
tolerance = data.get("tolerance_policy") or {}
if tolerance.get("numeric_default") != 0:
payload["missing"].append("tolerance_policy.numeric_default")
if tolerance.get("text_default") != "exact":
payload["missing"].append("tolerance_policy.text_default")
targets = data.get("parity_targets") or []
target_ids = {item.get("target_id", "") for item in targets}
missing_targets = REQUIRED_TARGETS - target_ids
for target in sorted(missing_targets):
payload["missing"].append(f"{target} missing")
priorities = [item.get("priority") for item in targets]
if priorities != sorted(priorities):
payload["missing"].append("priority order")
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
payload["message"] = (
"WBS-10 dotnet domain parity backlog validation passed."
if payload["gate"] == "PASS"
else "WBS-10 dotnet domain parity backlog validation failed."
)
out_path = backlog_path.parent.parent / "Temp" / "wbs_10_dotnet_domain_parity_backlog_v1.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(path)
return yaml.safe_load(path.read_text(encoding="utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet idempotency contract")
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml")
args = parser.parse_args(argv)
contract_path = Path(args.contract).resolve()
payload: dict[str, Any] = {
"formula_id": "WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1",
"gate": "FAIL",
"missing": [],
"evidence": {"contract": str(contract_path)},
}
try:
data = load_yaml(contract_path)
except FileNotFoundError:
payload["missing"].append("contract missing")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
if data.get("formula_id") != "WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1":
payload["missing"].append("formula_id")
if data.get("goal") != "중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다.":
payload["missing"].append("goal")
if data.get("lock_domain", {}).get("canonical_table") != "quantengine.workspace_lock":
payload["missing"].append("lock_domain.canonical_table")
if data.get("idempotency_key", {}).get("required") is not True:
payload["missing"].append("idempotency_key.required")
if data.get("lease_policy", {}).get("required") is not True:
payload["missing"].append("lease_policy.required")
policy = data.get("lease_policy") or {}
retry = policy.get("retry_policy") or {}
if retry.get("max_attempts") != 3:
payload["missing"].append("lease_policy.retry_policy.max_attempts")
if retry.get("backoff") != "exponential":
payload["missing"].append("lease_policy.retry_policy.backoff")
guards = data.get("duplicate_execution_guards") or []
if not guards:
payload["missing"].append("duplicate_execution_guards")
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
payload["message"] = (
"WBS-10 dotnet idempotency contract validation passed."
if payload["gate"] == "PASS"
else "WBS-10 dotnet idempotency contract validation failed."
)
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_idempotency_contract_v1.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(path)
return yaml.safe_load(path.read_text(encoding="utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet normalization contract")
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml")
args = parser.parse_args(argv)
contract_path = Path(args.contract).resolve()
payload: dict[str, Any] = {
"formula_id": "WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1",
"gate": "FAIL",
"missing": [],
"evidence": {"contract": str(contract_path)},
}
try:
data = load_yaml(contract_path)
except FileNotFoundError:
payload["missing"].append("contract missing")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
if data.get("formula_id") != "WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1":
payload["missing"].append("formula_id")
if data.get("goal") != "쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다.":
payload["missing"].append("goal")
write_path = data.get("canonical_write_path") or {}
if write_path.get("schema") != "engine_history":
payload["missing"].append("canonical_write_path.schema")
tables = write_path.get("tables") or []
for required in {
"source_observation",
"factor_definition",
"factor_observation",
"decision_event",
"decision_factor_evidence",
"outcome_evaluation",
} - set(tables):
payload["missing"].append(f"{required} missing")
if data.get("canonical_read_path", {}).get("view") != "engine_history.training_example_v1":
payload["missing"].append("canonical_read_path.view")
if not data.get("forbidden_patterns"):
payload["missing"].append("forbidden_patterns")
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
payload["message"] = (
"WBS-10 dotnet normalization contract validation passed."
if payload["gate"] == "PASS"
else "WBS-10 dotnet normalization contract validation failed."
)
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_normalization_contract_v1.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
REQUIRED_PAYLOADS = {
"factor_evidence",
"decision_event",
"collection_audit",
"scheduler_audit",
}
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(path)
return yaml.safe_load(path.read_text(encoding="utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet provenance contract")
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml")
args = parser.parse_args(argv)
contract_path = Path(args.contract).resolve()
payload: dict[str, Any] = {
"formula_id": "WBS_10_DOTNET_PROVENANCE_CONTRACT_V1",
"gate": "FAIL",
"missing": [],
"evidence": {"contract": str(contract_path)},
}
try:
data = load_yaml(contract_path)
except FileNotFoundError:
payload["missing"].append("contract missing")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
if data.get("formula_id") != "WBS_10_DOTNET_PROVENANCE_CONTRACT_V1":
payload["missing"].append("formula_id")
if data.get("goal") != "결정/팩터/수집 provenance payload를 표준화한다.":
payload["missing"].append("goal")
payloads = data.get("payloads") or []
payload_ids: set[str] = set()
for item in payloads:
payload_id = item.get("payload_id", "")
payload_ids.add(payload_id)
if not item.get("source"):
payload["missing"].append(f"{payload_id}.source")
if not item.get("required_fields"):
payload["missing"].append(f"{payload_id}.required_fields")
if "pass_condition" not in item:
payload["missing"].append(f"{payload_id}.pass_condition")
for required in sorted(REQUIRED_PAYLOADS - payload_ids):
payload["missing"].append(f"{required} missing")
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
payload["message"] = (
"WBS-10 dotnet provenance contract validation passed."
if payload["gate"] == "PASS"
else "WBS-10 dotnet provenance contract validation failed."
)
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_provenance_contract_v1.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(path)
return yaml.safe_load(path.read_text(encoding="utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet read model contract")
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml")
args = parser.parse_args(argv)
contract_path = Path(args.contract).resolve()
payload: dict[str, Any] = {
"formula_id": "WBS_10_DOTNET_READ_MODEL_CONTRACT_V1",
"gate": "FAIL",
"missing": [],
"evidence": {"contract": str(contract_path)},
}
try:
data = load_yaml(contract_path)
except FileNotFoundError:
payload["missing"].append("contract missing")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
if data.get("formula_id") != "WBS_10_DOTNET_READ_MODEL_CONTRACT_V1":
payload["missing"].append("formula_id")
if data.get("goal") != "운영 화면과 조회 API의 read model 경계를 분리한다.":
payload["missing"].append("goal")
models = data.get("read_models") or []
if len(models) < 3:
payload["missing"].append("read_models")
for model in models:
if not model.get("model_id"):
payload["missing"].append("model_id")
if not model.get("purpose"):
payload["missing"].append(f"{model.get('model_id', 'unknown')}.purpose")
if not model.get("source"):
payload["missing"].append(f"{model.get('model_id', 'unknown')}.source")
if not model.get("consumers"):
payload["missing"].append(f"{model.get('model_id', 'unknown')}.consumers")
if not model.get("fields"):
payload["missing"].append(f"{model.get('model_id', 'unknown')}.fields")
if not model.get("staleness_budget"):
payload["missing"].append(f"{model.get('model_id', 'unknown')}.staleness_budget")
rules = data.get("rules") or []
if len(rules) < 4:
payload["missing"].append("rules")
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
payload["message"] = (
"WBS-10 dotnet read model contract validation passed."
if payload["gate"] == "PASS"
else "WBS-10 dotnet read model contract validation failed."
)
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_read_model_contract_v1.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
REQUIRED_STATES = {"pending", "running", "succeeded", "failed", "retrying", "blocked"}
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(path)
return yaml.safe_load(path.read_text(encoding="utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet scheduler contract")
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml")
args = parser.parse_args(argv)
contract_path = Path(args.contract).resolve()
payload: dict[str, Any] = {
"formula_id": "WBS_10_DOTNET_SCHEDULER_CONTRACT_V1",
"gate": "FAIL",
"missing": [],
"evidence": {"contract": str(contract_path)},
}
try:
data = load_yaml(contract_path)
except FileNotFoundError:
payload["missing"].append("contract missing")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
if data.get("formula_id") != "WBS_10_DOTNET_SCHEDULER_CONTRACT_V1":
payload["missing"].append("formula_id")
if data.get("goal") != "스케줄러 상태 전이, 의존성, 재시도, 감사 추적을 표준화한다.":
payload["missing"].append("goal")
machine = data.get("state_machine") or {}
states = set(machine.get("states") or [])
if REQUIRED_STATES - states:
payload["missing"].append("state_machine.states")
transitions = machine.get("allowed_transitions") or {}
for state in REQUIRED_STATES:
if state not in transitions:
payload["missing"].append(f"allowed_transitions.{state}")
job_definitions = data.get("job_definitions") or []
job_ids = {item.get("job_id", "") for item in job_definitions}
for required in {"daily-collection", "hourly-price-update", "weekly-report", "monthly-optimization"} - job_ids:
payload["missing"].append(f"{required} missing")
if not data.get("audit_fields"):
payload["missing"].append("audit_fields")
if data.get("idempotency", {}).get("required") is not True:
payload["missing"].append("idempotency.required")
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
payload["message"] = (
"WBS-10 dotnet scheduler contract validation passed."
if payload["gate"] == "PASS"
else "WBS-10 dotnet scheduler contract validation failed."
)
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_scheduler_contract_v1.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())